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 82cb4cc..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": "local_polling", + "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 e14a245..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,20 +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" 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 @@ -103,6 +107,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 +127,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 +253,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 +303,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 +382,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 +406,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 +433,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 +462,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 +476,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( [ @@ -695,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" + } + } + } + } +}