From 61c01e14a43dd0354c8c490ae61a209b3f7f1594 Mon Sep 17 00:00:00 2001 From: teh-hippo <490309+teh-hippo@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:46:55 +1100 Subject: [PATCH 1/3] feat: conditional battery entities for solar-only inverters Skip battery entity creation when the FoxESS API reports hasBattery=false. Adds optional hasBattery config override. Reduces log noise for solar-only systems going offline at night. --- custom_components/foxess/sensor.py | 141 ++++++++++++++++------------- 1 file changed, 80 insertions(+), 61 deletions(-) diff --git a/custom_components/foxess/sensor.py b/custom_components/foxess/sensor.py index e14a245..5fb0041 100644 --- a/custom_components/foxess/sensor.py +++ b/custom_components/foxess/sensor.py @@ -80,6 +80,7 @@ CONF_GET_VARIABLES = "Restrict" CONF_V1_API = "Use_V1_Api" CONF_EVO = "Evo" +CONF_HAS_BATTERY = "hasBattery" RETRY_NEXT_SLOT = -1 RETRY_IN_5_MINS = 25 DNS_ERROR = 101 @@ -103,6 +104,7 @@ vol.Optional(CONF_GET_VARIABLES): cv.boolean, vol.Optional(CONF_V1_API): cv.boolean, vol.Optional(CONF_EVO): cv.boolean, + vol.Optional(CONF_HAS_BATTERY): cv.boolean, } ) @@ -122,6 +124,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= RestrictGetVar = config.get(CONF_GET_VARIABLES) V1_Api = config.get(CONF_V1_API) Evo = config.get(CONF_EVO) + hasBatteryOverride = config.get(CONF_HAS_BATTERY) _LOGGER.debug("API Key: %s", apiKey) _LOGGER.debug("Device SN: %s", devicesn) _LOGGER.debug("Device ID: %s", deviceID) @@ -247,7 +250,11 @@ async def async_update_data(): if not allData["online"]: if not geterror: - _LOGGER.warning("%s Inverter is off-line, waiting to retry", name) + hasBat = allData["addressbook"].get("hasBattery", True) + if not hasBat: + _LOGGER.debug("%s Inverter off-line, no battery fitted", name) + else: + _LOGGER.warning("%s Inverter is off-line, waiting to retry", name) else: _LOGGER.warning("%s Cloud timeout, retry in 1 minute", name) else: @@ -293,6 +300,12 @@ async def async_update_data(): ) return False + if hasBatteryOverride is not None: + hasBattery = hasBatteryOverride + else: + hasBattery = allData["addressbook"].get("hasBattery", True) + _LOGGER.debug("hasBattery: %s (override: %s)", hasBattery, hasBatteryOverride) + async_add_entities( [ FoxESSCurrent( @@ -366,22 +379,6 @@ async def async_update_data(): FoxESSVolt(coordinator, name, deviceID, "T Volt", "t-volt", "TVolt"), FoxESSReactivePower(coordinator, name, deviceID), FoxESSPowerFactor(coordinator, name, deviceID), - FoxESSTemp( - coordinator, - name, - deviceID, - "Bat Temperature", - "bat-temperature", - "batTemperature", - ), - FoxESSTemp( - coordinator, - name, - deviceID, - "Bat Temperature2", - "bat-temperature2", - "batTemperature_2", - ), FoxESSTemp( coordinator, name, @@ -406,30 +403,7 @@ async def async_update_data(): "inv-temperature", "invTemperation", ), - FoxESSBatSoC(coordinator, name, deviceID, "Bat SoC", "bat-soc", "SoC"), - FoxESSBatSoC(coordinator, name, deviceID, "Bat SoC1", "bat-soc1", "SoC_1"), - FoxESSBatSoC(coordinator, name, deviceID, "Bat SoC2", "bat-soc2", "SoC_2"), - FoxESSBatSoC(coordinator, name, deviceID, "Bat SoH", "bat-soh", "SOH"), - FoxESSPower( - coordinator, - name, - deviceID, - "Inverter Bat Power", - "inv-Bat-Power", - "invBatPower", - ), - FoxESSPower( - coordinator, - name, - deviceID, - "Inverter Bat Power2", - "inv-Bat-Power2", - "invBatPower_2", - ), - FoxESSBatMinSoC(coordinator, name, deviceID), - FoxESSBatMinSoConGrid(coordinator, name, deviceID), FoxESSSolarPower(coordinator, name, deviceID), - FoxESSEnergyThroughput(coordinator, name, deviceID), FoxESSEnergySolar(coordinator, name, deviceID), FoxESSInverter(coordinator, name, deviceID), FoxESSPowerString( @@ -456,22 +430,6 @@ async def async_update_data(): "feedIn-power", "feedinPower", ), - FoxESSPowerString( - coordinator, - name, - deviceID, - "Bat Discharge Power", - "bat-discharge-power", - "batDischargePower", - ), - FoxESSPowerString( - coordinator, - name, - deviceID, - "Bat Charge Power", - "bat-charge-power", - "batChargePower", - ), FoxESSPowerString( coordinator, name, deviceID, "Load Power", "load-power", "loadsPower" ), @@ -501,14 +459,9 @@ async def async_update_data(): ), FoxESSEnergyGridConsumption(coordinator, name, deviceID), FoxESSEnergyFeedin(coordinator, name, deviceID), - FoxESSEnergyBatCharge(coordinator, name, deviceID), - FoxESSEnergyBatDischarge(coordinator, name, deviceID), FoxESSEnergyLoad(coordinator, name, deviceID), FoxESSPVEnergyTotal(coordinator, name, deviceID), - FoxESSResidualEnergy(coordinator, name, deviceID), FoxESSResponseTime(coordinator, name, deviceID), - FoxESSMaxBatChargeCurrent(coordinator, name, deviceID), - FoxESSMaxBatDischargeCurrent(coordinator, name, deviceID), FoxESSRunningState( coordinator, name, @@ -520,6 +473,72 @@ async def async_update_data(): ] ) + if hasBattery: + async_add_entities( + [ + FoxESSTemp( + coordinator, + name, + deviceID, + "Bat Temperature", + "bat-temperature", + "batTemperature", + ), + FoxESSTemp( + coordinator, + name, + deviceID, + "Bat Temperature2", + "bat-temperature2", + "batTemperature_2", + ), + FoxESSBatSoC(coordinator, name, deviceID, "Bat SoC", "bat-soc", "SoC"), + FoxESSBatSoC(coordinator, name, deviceID, "Bat SoC1", "bat-soc1", "SoC_1"), + FoxESSBatSoC(coordinator, name, deviceID, "Bat SoC2", "bat-soc2", "SoC_2"), + FoxESSBatSoC(coordinator, name, deviceID, "Bat SoH", "bat-soh", "SOH"), + FoxESSPower( + coordinator, + name, + deviceID, + "Inverter Bat Power", + "inv-Bat-Power", + "invBatPower", + ), + FoxESSPower( + coordinator, + name, + deviceID, + "Inverter Bat Power2", + "inv-Bat-Power2", + "invBatPower_2", + ), + FoxESSBatMinSoC(coordinator, name, deviceID), + FoxESSBatMinSoConGrid(coordinator, name, deviceID), + FoxESSEnergyThroughput(coordinator, name, deviceID), + FoxESSPowerString( + coordinator, + name, + deviceID, + "Bat Discharge Power", + "bat-discharge-power", + "batDischargePower", + ), + FoxESSPowerString( + coordinator, + name, + deviceID, + "Bat Charge Power", + "bat-charge-power", + "batChargePower", + ), + FoxESSEnergyBatCharge(coordinator, name, deviceID), + FoxESSEnergyBatDischarge(coordinator, name, deviceID), + FoxESSResidualEnergy(coordinator, name, deviceID), + FoxESSMaxBatChargeCurrent(coordinator, name, deviceID), + FoxESSMaxBatDischargeCurrent(coordinator, name, deviceID), + ] + ) + if ExtPV: async_add_entities( [ From c317b26a598de533262977c89c0a4222886a82f8 Mon Sep 17 00:00:00 2001 From: teh-hippo <490309+teh-hippo@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:10:48 +1100 Subject: [PATCH 2/3] fix: set iot_class to cloud_polling The integration uses the FoxESS Cloud API (foxesscloud.com), not local device communication. --- custom_components/foxess/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/foxess/manifest.json b/custom_components/foxess/manifest.json index 82cb4cc..9ca704f 100644 --- a/custom_components/foxess/manifest.json +++ b/custom_components/foxess/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@macxq","@r-amado","@fozzieuk"], "dependencies": ["rest"], "documentation": "https://github.com/macxq/foxess-ha", - "iot_class": "local_polling", + "iot_class": "cloud_polling", "issue_tracker":"https://github.com/macxq/foxess-ha/issues", "version": "v0.4" } From 517f36e8fbd298d3b6361112691f1f69effe9fae Mon Sep 17 00:00:00 2001 From: teh-hippo <490309+teh-hippo@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:05:39 +1100 Subject: [PATCH 3/3] feat: add UI config flow for Settings > Integrations Add config flow so the integration can be set up via the HA UI. The setup step collects API key, device serial number, and name, then validates against the FoxESS Cloud API. Battery status is auto-detected from the API response and exposed as an option toggle alongside extended PV and Evo inverter mode. Existing YAML configurations continue to work unchanged. --- custom_components/foxess/__init__.py | 37 +++- custom_components/foxess/config_flow.py | 170 ++++++++++++++++++ custom_components/foxess/const.py | 18 ++ custom_components/foxess/manifest.json | 3 +- custom_components/foxess/sensor.py | 40 +++-- custom_components/foxess/strings.json | 35 ++++ custom_components/foxess/translations/en.json | 35 ++++ 7 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 custom_components/foxess/config_flow.py create mode 100644 custom_components/foxess/const.py create mode 100644 custom_components/foxess/strings.json create mode 100644 custom_components/foxess/translations/en.json diff --git a/custom_components/foxess/__init__.py b/custom_components/foxess/__init__.py index a514572..6cb9377 100644 --- a/custom_components/foxess/__init__.py +++ b/custom_components/foxess/__init__.py @@ -1 +1,36 @@ -"""The Foxess cloud integration.""" \ No newline at end of file +"""The FoxESS Cloud integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the FoxESS integration from YAML.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up FoxESS Cloud from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a FoxESS config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) \ No newline at end of file diff --git a/custom_components/foxess/config_flow.py b/custom_components/foxess/config_flow.py new file mode 100644 index 0000000..22656a7 --- /dev/null +++ b/custom_components/foxess/config_flow.py @@ -0,0 +1,170 @@ +"""Config flow for FoxESS Cloud integration.""" +from __future__ import annotations + +import hashlib +import time +from typing import Any + +import aiohttp +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_APIKEY, + CONF_DEVICEID, + CONF_DEVICESN, + CONF_EVO, + CONF_EXTPV, + CONF_HAS_BATTERY, + DEFAULT_NAME, + DOMAIN, + ENDPOINT_OA_DEVICE_DETAIL, + ENDPOINT_OA_DOMAIN, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_APIKEY): str, + vol.Required(CONF_DEVICESN): str, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + } +) + + +def _build_foxess_headers(api_key: str, path: str) -> dict[str, str]: + """Build authentication headers for the FoxESS OpenAPI.""" + timestamp = str(int(time.time() * 1000)) + # Uses literal \r\n (raw string), matching GetAuth in sensor.py + signature_text = rf"{path}\r\n{api_key}\r\n{timestamp}" + signature = hashlib.md5(signature_text.encode()).hexdigest() + return { + "token": api_key, + "timestamp": timestamp, + "signature": signature, + "lang": "en", + "Content-Type": "application/json", + } + + +async def _validate_api( + session: aiohttp.ClientSession, api_key: str, device_sn: str +) -> dict[str, Any]: + """Validate credentials by calling the FoxESS device detail endpoint.""" + path = ENDPOINT_OA_DEVICE_DETAIL + url = f"{ENDPOINT_OA_DOMAIN}{path}?sn={device_sn}" + headers = _build_foxess_headers(api_key, path) + + try: + async with session.get(url, headers=headers, ssl=False) as resp: + if resp.status == 401: + raise ValueError("invalid_auth") + if resp.status != 200: + raise ValueError("cannot_connect") + data = await resp.json() + except (aiohttp.ClientError, TimeoutError) as err: + raise ValueError("cannot_connect") from err + + errno = data.get("errno", -1) + if errno != 0: + msg = data.get("msg", "").lower() + if errno in (41807, 41808, 41809, 40256) or "token" in msg or "sign" in msg: + raise ValueError("invalid_auth") + if errno in (41930, 40261, 40257) or "device" in msg: + raise ValueError("device_not_found") + if errno == 40400: + raise ValueError("cannot_connect") + raise ValueError("unknown") + + result = data.get("result") + if result is None: + raise ValueError("unknown") + return result + + +class FoxESSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for FoxESS Cloud.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + # Abort if YAML platform is already configured + for state in self.hass.states.async_all("sensor"): + if state.entity_id.startswith("sensor.foxess_"): + return self.async_abort(reason="yaml_in_use") + + errors: dict[str, str] = {} + + if user_input is not None: + api_key = user_input[CONF_APIKEY] + device_sn = user_input[CONF_DEVICESN] + name = user_input.get(CONF_NAME, DEFAULT_NAME) + + session = async_get_clientsession(self.hass) + try: + result = await _validate_api(session, api_key, device_sn) + except ValueError as err: + errors["base"] = str(err) + else: + await self.async_set_unique_id(device_sn) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{name} ({device_sn})", + data={ + CONF_APIKEY: api_key, + CONF_DEVICESN: device_sn, + CONF_DEVICEID: device_sn, + CONF_NAME: name, + CONF_HAS_BATTERY: bool(result.get("hasBattery")), + }, + options={ + CONF_EXTPV: False, + CONF_EVO: False, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + return FoxESSOptionsFlow() + + +class FoxESSOptionsFlow(OptionsFlow): + """Handle FoxESS options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = self.config_entry.options + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_EXTPV, + default=options.get(CONF_EXTPV, False), + ): bool, + vol.Optional( + CONF_EVO, + default=options.get(CONF_EVO, False), + ): bool, + } + ), + ) diff --git a/custom_components/foxess/const.py b/custom_components/foxess/const.py new file mode 100644 index 0000000..c55d2de --- /dev/null +++ b/custom_components/foxess/const.py @@ -0,0 +1,18 @@ +"""Constants for the FoxESS Cloud integration.""" + +DOMAIN = "foxess" + +ENDPOINT_OA_DOMAIN = "https://www.foxesscloud.com" +ENDPOINT_OA_DEVICE_DETAIL = "/op/v1/device/detail" + +CONF_APIKEY = "apiKey" +CONF_DEVICESN = "deviceSN" +CONF_DEVICEID = "deviceID" +CONF_EXTPV = "extendPV" +CONF_XTZONE = "xtZone" +CONF_GET_VARIABLES = "Restrict" +CONF_V1_API = "Use_V1_Api" +CONF_EVO = "Evo" +CONF_HAS_BATTERY = "hasBattery" + +DEFAULT_NAME = "FoxESS" diff --git a/custom_components/foxess/manifest.json b/custom_components/foxess/manifest.json index 9ca704f..3190616 100644 --- a/custom_components/foxess/manifest.json +++ b/custom_components/foxess/manifest.json @@ -2,9 +2,10 @@ "domain": "foxess", "name": "HA & FoxESSCloud integration", "codeowners": ["@macxq","@r-amado","@fozzieuk"], + "config_flow": true, "dependencies": ["rest"], "documentation": "https://github.com/macxq/foxess-ha", "iot_class": "cloud_polling", "issue_tracker":"https://github.com/macxq/foxess-ha/issues", - "version": "v0.4" + "version": "v0.4" } diff --git a/custom_components/foxess/sensor.py b/custom_components/foxess/sensor.py index 5fb0041..89ac7a1 100644 --- a/custom_components/foxess/sensor.py +++ b/custom_components/foxess/sensor.py @@ -43,6 +43,19 @@ from homeassistant.helpers.icon import icon_for_battery_level import homeassistant.helpers.config_validation as cv +from .const import ( + CONF_APIKEY, + CONF_DEVICESN, + CONF_DEVICEID, + CONF_EXTPV, + CONF_XTZONE, + CONF_GET_VARIABLES, + CONF_V1_API, + CONF_EVO, + CONF_HAS_BATTERY, + DEFAULT_NAME, +) + _LOGGER = logging.getLogger(__name__) _ENDPOINT_OA_DOMAIN = "https://www.foxesscloud.com" _ENDPOINT_OA_BATTERY_SETTINGS = "/op/v0/device/battery/soc/get?sn=" @@ -71,21 +84,11 @@ BATTERY_LEVELS = {"High": 80, "Medium": 50, "Low": 25, "Empty": 10} -CONF_APIKEY = "apiKey" -CONF_DEVICESN = "deviceSN" -CONF_DEVICEID = "deviceID" CONF_SYSTEM_ID = "system_id" -CONF_EXTPV = "extendPV" -CONF_XTZONE = "xtZone" -CONF_GET_VARIABLES = "Restrict" -CONF_V1_API = "Use_V1_Api" -CONF_EVO = "Evo" -CONF_HAS_BATTERY = "hasBattery" RETRY_NEXT_SLOT = -1 RETRY_IN_5_MINS = 25 DNS_ERROR = 101 -DEFAULT_NAME = "FoxESS" DEFAULT_VERIFY_SSL = False # True SCAN_MINUTES = 1 # number of minutes betwen API requests @@ -714,6 +717,23 @@ async def async_update_data(): ) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up FoxESS sensor from a config entry.""" + config = { + CONF_NAME: entry.data.get(CONF_NAME, DEFAULT_NAME), + CONF_DEVICEID: entry.data[CONF_DEVICEID], + CONF_DEVICESN: entry.data[CONF_DEVICESN], + CONF_APIKEY: entry.data[CONF_APIKEY], + CONF_HAS_BATTERY: entry.data.get(CONF_HAS_BATTERY), + CONF_EXTPV: entry.options.get(CONF_EXTPV, False), + CONF_EVO: entry.options.get(CONF_EVO, False), + CONF_XTZONE: False, + CONF_GET_VARIABLES: False, + CONF_V1_API: True, + } + await async_setup_platform(hass, config, async_add_entities) + + class GetAuth: def get_signature(self, token, path, lang="en"): """ diff --git a/custom_components/foxess/strings.json b/custom_components/foxess/strings.json new file mode 100644 index 0000000..7a17188 --- /dev/null +++ b/custom_components/foxess/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "title": "FoxESS Cloud", + "description": "Enter your FoxESS OpenAPI key and device serial number.", + "data": { + "apiKey": "API Key", + "deviceSN": "Device Serial Number", + "name": "Name" + } + } + }, + "error": { + "cannot_connect": "Unable to connect to FoxESS Cloud.", + "invalid_auth": "Invalid API key.", + "device_not_found": "Device not found. Check serial number.", + "unknown": "Unexpected error." + }, + "abort": { + "already_configured": "This device is already configured.", + "yaml_in_use": "This integration is configured via YAML. Remove the sensor platform entry from configuration.yaml first." + } + }, + "options": { + "step": { + "init": { + "data": { + "extendPV": "More than 6 PV strings", + "Evo": "Evo series inverter" + } + } + } + } +} diff --git a/custom_components/foxess/translations/en.json b/custom_components/foxess/translations/en.json new file mode 100644 index 0000000..7a17188 --- /dev/null +++ b/custom_components/foxess/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "title": "FoxESS Cloud", + "description": "Enter your FoxESS OpenAPI key and device serial number.", + "data": { + "apiKey": "API Key", + "deviceSN": "Device Serial Number", + "name": "Name" + } + } + }, + "error": { + "cannot_connect": "Unable to connect to FoxESS Cloud.", + "invalid_auth": "Invalid API key.", + "device_not_found": "Device not found. Check serial number.", + "unknown": "Unexpected error." + }, + "abort": { + "already_configured": "This device is already configured.", + "yaml_in_use": "This integration is configured via YAML. Remove the sensor platform entry from configuration.yaml first." + } + }, + "options": { + "step": { + "init": { + "data": { + "extendPV": "More than 6 PV strings", + "Evo": "Evo series inverter" + } + } + } + } +}