Added
- New
sensor.foxess_pv3_powerandsensor.foxess_pv4_power— per-string power for multi-MPPT inverters (issue #15). KH-series and other inverters with more than two MPPT inputs reportpv3Power/pv4Power, which the integration never asked for. Both follow the existing PV1/PV2 pattern exactly: diagnostic category, kW, disabled by default — enable them in Settings → Devices & Services → Entities. On inverters with only two strings the variables are absent from the API response, so the entities simply read unavailable and nothing else is affected. Note the overview card's PV breakdown still shows PV1 + PV2 only. - New
sensor.foxess_solar_pv_energy— "Solar PV Energy" (C-041). Cumulative photovoltaic-only yield, from the FoxESSPVEnergyTotalvariable, which the integration did not previously poll at all. Energy device class,total_increasing, kWh, enabled by default. This is the sensor to use as the Home Assistant Energy dashboard's solar source. On inverter models that do not reportPVEnergyTotalthe entity simply reads unavailable — the FoxESS API silently omits an unsupported variable from the response (verified against a live KH10), so the rest of the poll is unaffected and no reconfiguration is needed.
Fixed
- Two sensors were mislabelled as solar when they measure inverter output (C-041), which caused the HA Energy dashboard to count battery discharge twice.
sensor.foxess_solar_generation_energy("Solar Generation Energy" / German "Solarenergie") andsensor.foxess_generation("Generation") are fed by the FoxESSgeneration/generationPowervariables — the inverter's cumulative AC output energy and power, which include everything the battery discharged. The solar name and solar icon led users to wire the energy one into the Energy dashboard as their solar source alongside the battery-discharge sensor as their battery source; on a live KH10 one night's computed home consumption came out as 26.6 kWh against an actual house load of 7.2 kWh (raw cloud history for that night:generation+18.2 kWh,PVEnergyTotal+0.2 kWh,pvPowerflat at 0). They are now named "Inverter Output Energy" and "Inverter Output" in every language, and no longer carry solar icons. This is a friendly-name change only — unique IDs are unchanged and Home Assistant keeps the entity IDs of already-registered entities, so existing automations, dashboards, statistics and history are untouched. Fresh installs get entity IDs derived from the new names (sensor.foxess_inverter_output_energy,sensor.foxess_inverter_output). If you had either sensor set as your Energy dashboard solar source, switch it to the newsensor.foxess_solar_pv_energy. scripts/collect_ha_session.pyrecorded inverter output in itssolar_powercolumn when the locale-aware entity lookup was unavailable — the English fallback pointed atsensor.foxess_generationinstead ofsensor.foxess_solar_power.docs/api/foxess-cloud-api.mddocumentedgenerationas "Lifetime solar generation energy" — the origin of the mislabelling. It now describesgeneration/generationPoweras the inverter's AC output side, documentsPVEnergyTotalas the PV-only counter, records thattodayYieldreads0.0on the KH10 and must not be relied on, and documents thatreal/querysilently omits unsupported variables (errno: 0) rather than rejecting the request.- Every scheduler write failed with
FoxESS API error 40257: Parameters do not meet expectationson H3-15.0-Smart, H3-12.0-M and EVO 10-5-H (issues #17, #14, #12; new C-042). Force Discharge, Feed-in and Force Charge all failed immediately — as did session teardown — while polling, real-time data and setup worked normally. Root cause: the force-discharge power (fdPwr) written into every schedule group was derived ascapacity_kw * 1050, a factor reverse-engineered from a KH10. The FoxESS API declares the acceptedfdPwrceiling per device (POST /op/v3/device/scheduler/get→properties.fdpwr.range.max), and on a KH10 that ceiling is exactly10 * 1050 = 10500W — so the heuristic was right there by coincidence. Other model families declare the plain nameplate rating, so the same arithmetic overshot by 5 % (12600 W on a 12 kW H3, 5250 W on a 5 kW EVO) and the API rejected the whole write. The integration now reads the device's declared ranges once at setup and clampsfdPwr,fdSocandminSocOnGridinto them at the single point every schedule write passes through, so no service argument or pacing value can produce a rejected payload.max_power_w(the ceiling the pacing algorithms work against) is now the lower of the capacity-derived value and the declared maximum. Installs whose devices do not expose the declared ranges keep the previous behaviour unchanged, and devices whose declared ceiling already equalscapacity * 1050(KH family) write a byte-identical payload — no reconfiguration is needed for anyone. Regression classtests/test_scheduler_device_limits.py. - Diagnostics always reported
inverter_model: null. The field read a non-existentmodelattribute instead ofdevice_type, so every diagnostics download from every user showed a null model — which made the three 40257 reports above look like a model-recognition problem when the model string is not used for anything. Diagnostics now report the real model name (e.g.KH10,H3-12.0-M) plus a newscheduler_limitssection carrying the device-declaredfdPwrceiling and supported work modes, so a future rejected write is diagnosable from the download alone (C-020, C-026). - A work mode the inverter does not list as supported now logs a warning naming the mode and the device's own supported list, instead of only surfacing the API's opaque 40257. The write is still attempted, so a device that under-reports its capabilities keeps working.
- Add-on could not start on Home Assistant OS (issues #9, #10). The add-on container died before reaching
run.sh, so the integration was never copied into/config/custom_components/— users saw the add-on install successfully but nothing appear under Settings → Devices & Services → Integrations.foxess-control/config.yamldid not declareinit:, and the Supervisor defaults that option totrue(vol.Optional(ATTR_INIT, default=True)insupervisor/apps/validate.py, passed to Docker asinit=insupervisor/docker/app.py), which puts Docker's own init process (tini) at PID 1. The add-on is built on Home Assistant's Alpine base images, which ship s6-overlay v3 (3.2.2.0) and declareENTRYPOINT ["/init"]; s6-overlay v3 requires its/initto be PID 1 and aborts withs6-overlay-suexec: fatal: can only run as pid 1otherwise. Fixed by declaringinit: false, which the add-on documentation has required for s6-overlay-v3 base images since 2022. This is the second of two stacked failures at the same choke point: the[FATAL tini (7)] exec /init failed: Permission deniedvariant both users quoted was the custom AppArmor profile that omitted/init(dropped in 1.0.19), and removing tini from the exec chain rules out both. No change to the integration itself — entity IDs, unique IDs and behaviour are untouched; only how the installer container boots. Regression classtests/test_addon_supervisor_boot.py, which builds the real add-on image and boots it the way the Supervisor does.
Changed
- Test infrastructure: two pytest runs on one machine no longer destroy each other (new C-043). No user-visible change — this is a developer-workflow fix. E2E container names were derived only from the xdist worker id (
ha-e2e-gw0), and theha_e2efixture removes that name at setup as a hygiene step, so the second of two concurrent runs issuedpodman rm -fagainst the first run's live container; the losing run then polled a dead container until its budget expired and reportedTimeoutError: HA did not become ready within 120sat setup, producing cascades of failures that looked like product regressions. Reproduced directly by running the same two-test subset from two working directories at once:1 passed, 1 skipped, 2 errors(121.9s setup) versus2 passed, 2 skippedfor the run that won the race; after the fix both runs give2 passed, 2 skippedin ~31s. Container names now carry a checkout token, the worker process's pid and the xdist worker id, and cleanup removes a container only when it can prove the container is this run's own or belongs to a run that is provably dead — so a crashed run's leftovers are still reclaimed but a live run's are never touched. Separately,_find_free_port()probed a host port and released it beforepodman run -pbound it, so a concurrent run's probe could be handed the same port (measured: 6 concurrent processes x 50 ports produced duplicates in 5 of 6 rounds); ports now come from an OS probe paired with a host-wide claim held for the life of the run, under a cross-process lock, so a collision is impossible rather than unlikely.tests/soak/uses the same helpers, so a soak run and an E2E run can share a host, and.githooks/pre-pushno longer blanket-stops everyha-e2e-*container. Regression classtests/test_e2e_container_isolation.py(29 tests). This restores local E2E as a trustworthy signal (C-031) — CI is unaffected, as it runs one job per machine. - Test infrastructure: the simulator now models device-declared scheduler ranges (
/op/v3/device/scheduler/get), thedeviceType/functionfields of device detail, and rejects out-of-range groups the way the live API does. Test HTTP servers bind127.0.0.1rather thanlocalhost— a dual-stacklocalhostbind assigns different ports to the IPv4 and IPv6 sockets, so the advertised port was only valid for one family and concurrently-running test servers could receive each other's requests (C-031).