diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index e84c367a5..cd47a6e4f 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -166,6 +166,7 @@ dlimit dlopen dnoregion docstrings +dschg dstamp dstart dtype @@ -275,6 +276,8 @@ gridconsumptionpower Groq growatt growattsph +guoxia +guoxiatech GWMQTT HACS hadashboard @@ -308,6 +311,7 @@ idag idef idetails idxs +iess iflux importlimit inday @@ -612,6 +616,7 @@ slugified slugifies slugify slugifying +smartflex snakeviz snprintf socb diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 4f6ad47e3..b823e422b 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -467,6 +467,44 @@ def load_component_class(component_info): "phase": 1, "can_restart": True, }, + "hanchu": { + "class": "hanchu.HanchuAPI", + "name": "Hanchu ESS Cloud API", + "inverter": True, + "event_filter": "predbat_hanchu_", + "args": { + # The e-mail address and password the user signs into the Hanchu phone app with. There + # is no developer portal and no API key: the app account IS the credential. + "account": {"required": False, "secret": True, "config": "hanchu_account"}, + "password": {"required": False, "secret": True, "config": "hanchu_password"}, + "inverter_sn": {"required": False, "config": "hanchu_inverter_sn"}, + "automatic": {"required": False, "default": False, "config": "hanchu_automatic"}, + "automatic_ignore_pv": {"required": False, "default": False, "config": "hanchu_automatic_ignore_pv"}, + # On by default, matching alphaess_control_enable and sunsynk_control_enable: an + # inverter component that does not drive the inverter is not what a user configuring it + # expects. Set false for monitoring only. switch.predbat_set_read_only still gates + # every write. + "control_enable": {"required": False, "default": True, "config": "hanchu_control_enable"}, + # The escape hatch for the one genuinely inferred behaviour in this component: nothing + # documents which work mode honours the timed slots, so "User-defined" is a deduction. + # Set false to write the windows and leave the mode exactly as the user set it. + "work_mode_control": {"required": False, "default": True, "config": "hanchu_work_mode_control"}, + # The API reports no battery power limit directly - it is taken from the charge power + # setting's own upper bound. This is the escape hatch for a user who knows their pack's + # real limit. + "battery_rate_max": {"required": False, "config": "hanchu_battery_rate_max"}, + # Same idea for the inverter's AC rating, which is taken from the discharge power + # setting's upper bound. Hanchu units come in 3.68, 5, 6 and 10 kW. + "inverter_limit": {"required": False, "config": "hanchu_inverter_limit"}, + "api_delay": {"required": False, "default": 1.0, "config": "hanchu_api_delay"}, + "min_write_interval": {"required": False, "default": 60, "config": "hanchu_min_write_interval"}, + }, + # Gate activation on having an account. Without this the component would start for every + # instance, since all individual args are optional. + "required_or": ["account"], + "phase": 1, + "can_restart": True, + }, "enphase": { "class": "enphase.EnphaseAPI", "name": "Enphase API", diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 0e1021cf3..325f2701b 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2359,6 +2359,51 @@ "charge_discharge_with_rate": False, "target_soc_used_for_discharge": True, }, + "HanchuCloud": { + "name": "HanchuCloud", + "has_rest_api": False, + "has_mqtt_api": False, + # CHG_PWR_LMT / DSCHG_PWR_LMT are real watt setpoints, not an "unrestricted" flag, and a + # rate of ZERO is how Predbat signals freeze in both directions. + "output_charge_control": "power", + # Everything goes through the device's timed slot table. The one immediate primitive this + # API has (fastChargeDischarge) is duration-based and would lapse behind Predbat's back, so + # it is deliberately unused - see hanchu_const.py. + "charge_control_immediate": False, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + # DTU_AC_CHG_SOC_LMT, the grid-charge limit. NOTE it only accepts 20-100 on this hardware, so + # a charge target below 20% cannot be expressed at all and is clamped, with a warning, by + # the component. CHG_BAT_SOC_LMT also caps solar charging, so it is never written. + "has_target_soc": True, + # DSCHG_BAT_SOC_LMT, which accepts only 5-45 - the mirror-image limitation. + "has_reserve_soc": True, + # There is no pause operation anywhere in this API, so Predbat expresses freeze through the + # rate entities instead. + "has_timed_pause": False, + # Anything other than HH:MM:SS makes inverter.py replace the published select entities with + # its own dummies and the window never reaches the component. The wire format is seconds + # since midnight; the conversion happens at the payload boundary. + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "has_ge_eco_toggle": False, + "time_button_press": True, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + # Wrap-around behaviour is undocumented for the TCT_/TDT_ slot pairs - as is everything + # else about this API - so Predbat splits the window and slot 2 carries the remainder + # rather than writing a window whose meaning is unknown. + "can_span_midnight": False, + "charge_discharge_with_rate": False, + "target_soc_used_for_discharge": True, + }, "SunsynkCloud": { "name": "SunsynkCloud", "has_rest_api": False, @@ -2824,6 +2869,17 @@ "sunsynk_automatic_ignore_pv": {"type": "boolean"}, "sunsynk_control_enable": {"type": "boolean"}, "sunsynk_battery_nominal_voltage": {"type": "float"}, + "hanchu_account": {"type": "string", "empty": False}, + "hanchu_password": {"type": "string", "empty": False}, + "hanchu_inverter_sn": {"type": "string|string_list", "empty": False}, + "hanchu_automatic": {"type": "boolean"}, + "hanchu_automatic_ignore_pv": {"type": "boolean"}, + "hanchu_control_enable": {"type": "boolean"}, + "hanchu_work_mode_control": {"type": "boolean"}, + "hanchu_battery_rate_max": {"type": "float"}, + "hanchu_inverter_limit": {"type": "float"}, + "hanchu_api_delay": {"type": "float"}, + "hanchu_min_write_interval": {"type": "integer"}, "alphaess_app_id": {"type": "string", "empty": False}, "alphaess_app_secret": {"type": "string", "empty": False}, "alphaess_inverter_sn": {"type": "string|string_list", "empty": False}, diff --git a/apps/predbat/hanchu.py b/apps/predbat/hanchu.py new file mode 100644 index 000000000..718ee326a --- /dev/null +++ b/apps/predbat/hanchu.py @@ -0,0 +1,2750 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Hanchu ESS cloud API library +# ----------------------------------------------------------------------------- + +"""Hanchu ESS cloud integration for Predbat. + +Registers each Hanchu DTU discovered on the user's Hanchu app account as a ``HanchuCloud`` +Predbat inverter, publishing monitoring sensors and schedule control entities. The inverter +owns the timing: Predbat writes a timed charge window and a timed discharge window into the +device's own slot table and the device acts on them, so there is no per-minute work mode to +derive. + +NO VENDOR DOCUMENTATION EXISTS. Every endpoint, field and parameter range used here was +learned by reading three MIT-licensed Home Assistant integrations, and the provenance of each +one - and whether it was read once, corroborated across repos, or merely inferred - is recorded +in hanchu_const.py. Read that file's header before trusting anything here. No code, structure +or comment from those integrations is reproduced; only the wire facts. + +WRITES ARE BATCHED, DELIBERATELY. The reference integration stages every control change in +memory and flushes the whole set in ONE iotSet call behind an explicit button, and says in its +own docstring that this exists to avoid rate limiting. Predbat re-plans every few minutes and +touches up to nineteen settings, so a per-setting write would be nineteen calls a cycle against +an API with no documented budget. build_write_payload() therefore assembles the complete +control state as a single dict, _write_payload() sends it in one call, and it is sent only when +it differs from the last payload the device accepted. + +AUTH IS A PASSWORD LOGIN, not OAuth. The user's own Hanchu app e-mail and password are +exchanged for a session token, which is refreshed proactively at ~25 days and re-obtained by +logging in again if the API ever answers 401. Predbat holds the credentials, so an expired or +rejected token is always recoverable without user action - unlike the reference integration, +which has to ask Home Assistant to re-authenticate. + +TWO SOC LIMITS ARE NARROWER THAN PREDBAT'S. The charge target goes in the grid-charge limit, +which accepts 20-100, and the discharge floor key accepts 5-45. A plan asking to charge to 10% or +to export down to 60% cannot be expressed on this hardware at all. Those values are CLAMPED and +the clamp is logged, because sending an out-of-range value would either be rejected or silently +ignored, and both look identical to a window that never ran. The overall charge ceiling, +CHG_BAT_SOC_LMT, also caps solar charging, so it is never written. +""" + +import asyncio +import json +import os +import statistics +import time +from datetime import datetime +import aiohttp +from component_base import ComponentBase +from coordinator import inverter_record +from hanchu_const import ( + HANCHU_ACK_MAP, + HANCHU_ACK_OK, + HANCHU_AC_COUPLED_FIELD, + HANCHU_BASE_URL, + HANCHU_CACHE_CONFIG, + HANCHU_CACHE_CONTROL, + HANCHU_CACHE_STATIC, + HANCHU_CAPACITY_FIELD, + HANCHU_CODE_FAILED, + HANCHU_CODE_OK, + HANCHU_CODE_REFRESH_REJECTED, + HANCHU_CODE_UNAUTHORISED, + HANCHU_CONTROL_KEYS, + HANCHU_DEBUG_REDACT_KEYS, + HANCHU_DEV_TYPE_DTU, + HANCHU_DEVICE_STATUS_FIELD, + HANCHU_DEVICE_STATUS_OFFLINE, + HANCHU_DEVICE_STATUSES, + HANCHU_DEVICE_SN_FIELD, + HANCHU_DEVICE_TYPE_FIELD, + HANCHU_ENDPOINTS, + HANCHU_ENERGY, + HANCHU_HEADER_LOCALE, + HANCHU_HEADER_PLATFORM, + HANCHU_HEADER_TOKEN, + HANCHU_KEY_CHARGE_END, + HANCHU_KEY_CHARGE_POWER, + HANCHU_KEY_CHARGE_SOC, + HANCHU_KEY_CHARGE_START, + HANCHU_KEY_DISCHARGE_END, + HANCHU_KEY_DISCHARGE_POWER, + HANCHU_KEY_DISCHARGE_SOC, + HANCHU_KEY_DISCHARGE_START, + HANCHU_KEY_GRID_CHARGE_SOC, + HANCHU_KEY_WORK_MODE, + HANCHU_MENU_ARRAY_SLOT_TYPES, + HANCHU_MENU_CODE, + HANCHU_MENU_DATA, + HANCHU_MENU_ENABLE_CYCLE, + HANCHU_MENU_ITEMS, + HANCHU_MENU_LISTENER, + HANCHU_MENU_LISTENER_SHOW, + HANCHU_MENU_MAX, + HANCHU_MENU_MIN, + HANCHU_MENU_SECTION, + HANCHU_MENU_SIGNAL, + HANCHU_MENU_TYPE, + HANCHU_POWER_STEP, + HANCHU_RANGES, + HANCHU_REFRESH_MIN_INTERVAL, + HANCHU_RETRIES, + HANCHU_SECONDS_PER_DAY, + HANCHU_SLOT_COUNT, + HANCHU_SLOT_DISABLED, + HANCHU_SOC_SCALE, + HANCHU_STATION_FIELD, + HANCHU_STORAGE_MODULE, + HANCHU_SUCCESS_BY_CODE_ENDPOINTS, + HANCHU_TELEMETRY, + HANCHU_TELEMETRY_NEGATE, + HANCHU_TELEMETRY_UNIT_FIELD, + HANCHU_TIMEOUT, + HANCHU_TOKEN_REFRESH_SECONDS, + HANCHU_TTL_CONFIG, + HANCHU_TTL_ENERGY, + HANCHU_TTL_POWER, + HANCHU_TTL_STATIC, + HANCHU_WORK_MODE_FOR_SCHEDULE, + HANCHU_WORK_MODES, + HANCHU_FAILED_WRITE_INTERVAL, + HANCHU_GENERATOR_FIELD, + HANCHU_WRITE_FAILURE_NOTIFY, + HANCHU_WRITE_RETRY_DELAY, + clamp_range, + hhmmss_to_seconds, + scale_to_watts, + seconds_to_hhmmss, + split_window_seconds, + window_is_empty, +) + +# The behaviour a HanchuCloud inverter has, stated for the discovery record's capabilities. A +# literal, never read back from INVERTER_DEF: the record has to rebuild the row on its own, or the +# completeness test proves nothing. +# +# support_feedin_first is False because there is no feed-in-first work mode among the four this API +# exposes. charge_control_immediate is False because everything goes through the timed slot table - +# the one immediate primitive this API has (fastChargeDischarge) is duration-based and would lapse +# behind Predbat's back, so it is deliberately unused. +HANCHU_CAPABILITIES = { + "support_charge_freeze": True, + "support_discharge_freeze": True, + "support_feedin_first": False, + "can_span_midnight": False, + "charge_discharge_with_rate": False, + "charge_control_immediate": False, + "target_soc_used_for_discharge": True, +} + + +class HanchuAPI(ComponentBase): + """Hanchu ESS cloud component.""" + + # Trace every request and response while this integration beds in. There is no vendor + # specification and nobody on the project has Hanchu hardware, so a tester's log is the only + # evidence available for everything hanchu_const.py marks INFERRED. Flip to False once the + # inferred items have been confirmed. + api_debug = True + + def initialize( + self, + account="", + password="", + inverter_sn=None, + automatic=False, + automatic_ignore_pv=False, + control_enable=True, + work_mode_control=True, + battery_rate_max=None, + inverter_limit=None, + api_delay=1, + min_write_interval=60, + **kwargs, + ): + """Initialise the Hanchu component from its resolved config args. + + ComponentBase.__init__ calls initialize(**kwargs); the Components registry has already + resolved each arg from its hanchu_* config key and passes it BY ARG NAME (e.g. account <- + hanchu_account), exactly like alphaess/sunsynk/fox. Consume the kwargs directly - do NOT + re-derive with get_arg("account"): that bare name is not in apps.yaml (the key is + hanchu_account), so it would always return the default. + """ + self.log("Info: HanchuAPI initialising") + self.account = account or "" + self.password = password or "" + self.automatic = automatic + self.automatic_ignore_pv = automatic_ignore_pv + self.control_enable = control_enable + self.work_mode_control = work_mode_control + self.inverter_sn_filter = inverter_sn if isinstance(inverter_sn, list) else ([inverter_sn] if inverter_sn else []) + self.battery_rate_max_override = float(battery_rate_max) if battery_rate_max else 0.0 + self.inverter_limit_override = float(inverter_limit) if inverter_limit else 0.0 + # Devices already warned that the menu gave no power bounds, so the warning is said once. + self.warned_default_power = set() + # Consecutive rejected settings writes per serial, and whether the user has been told. + self.write_failures = {} + self.write_failure_notified = set() + self.api_delay = max(0, float(api_delay or 0)) + self.min_write_interval = max(0, int(min_write_interval or 0)) + # An instance attribute rather than the constant used inline, so the unit tests can zero it: + # it is a real wall-clock pause between a failed write and its one retry, and nothing in a + # test talks to the real cloud. Not a config key - there is no reason a user would tune it. + self.write_retry_delay = HANCHU_WRITE_RETRY_DELAY + + self.device_list = [] + self.device_detail = {} + self.device_values = {} + self.device_energy = {} + # Per-serial control values last READ back from the device, and the per-serial parameter + # ranges the menu endpoint reports. Ranges are per device because the menu overrides the + # built-in defaults - a 3.6 kW unit and a 5 kW unit do not share a charge power ceiling. + self.device_settings = {} + self.device_ranges = {} + self.local_schedule = {} + # The last payload the device ACCEPTED, per serial. Serves three purposes: change + # detection (so an unchanged plan sends nothing), the pacing baseline, and the state a + # restart resumes from. + self.applied_payload = {} + self.last_write_time = {} + # One settings write in flight at a time. The schedule button and the reconcile loop can + # both reach _write_payload, and a single call can take 15-45s; two overlapping control + # writes are refused by the cloud with error 300004 (HARDWARE, batpred#5305). + self.write_lock = asyncio.Lock() + # Serials Predbat has actually been asked to drive, i.e. whose write button has been + # pressed at least once. The reconcile loop only re-applies for these, so a startup cycle + # can never clobber an inverter's existing slots before there is a plan to apply. + self.control_active = set() + + self._token = "" + self._token_time = 0.0 + self._last_refresh_attempt = 0.0 + self._tier_refreshed = {} + self._cache_restored = False + self._restore_had_error = False + # Clamps already reported, keyed (sn, key), so a limitation that persists for hours is + # explained once rather than every cycle. + self._clamp_logged = set() + # The most recent body-level API failure message (msg only - never a credential), and + # whether the last discovery attempt actually reached the API, so an empty account can be + # told apart from a failed call. + self.last_api_error = "" + self.discovery_ok = None + # Per serial: whether the datalogger is online (devStatus), None while unknown or pending. + self.device_online = {} + # Per serial: which time-slot format the menu describes ("slots", "array" or "unknown"), and the + # work modes its TCT_/TDT_ slots are shown in. Absent until a menu has been read. + self.slot_model = {} + self.slot_work_modes = {} + self._slot_notes_logged = set() + # One HTTP session for every call, opened on first use and closed by final(). Hanchu's cloud + # takes 7-45s to answer; a session per request made timeouts and retries far more common. + self._session = None + if not self.control_enable: + self.log("Info: Hanchu control is disabled (hanchu_control_enable is false); monitoring only") + if not self.work_mode_control: + self.log("Info: Hanchu work mode control is disabled (hanchu_work_mode_control is false); the timed windows are written but the work mode is left exactly as the user set it") + + # ------------------------------------------------------------------ + # Request layer + # ------------------------------------------------------------------ + + def _headers(self): + """Return the headers every Hanchu request carries. + + appPlat identifies the client to the server and locale selects the language of any message + it sends back. The session token goes in its own header, not in Authorization. + """ + headers = {"Content-Type": "application/json", "Accept": "*/*", "appPlat": HANCHU_HEADER_PLATFORM, "locale": HANCHU_HEADER_LOCALE} + if self._token: + headers[HANCHU_HEADER_TOKEN] = self._token + return headers + + @staticmethod + def redact(payload, path=""): + """Return a log-safe copy of a payload with credentials masked. + + Two things are masked. The credential KEYS, which covers the login body's password. And, + for the login and refresh endpoints ONLY, the envelope's `data` field: on those two paths + `data` IS the session token - a bare string, not an object - so masking by key name alone + would put full control of the user's inverter into the log. One reference integration masks + exactly these two paths' `data` for exactly this reason. + + Everything else is left intact deliberately. code, msg and every data field of every other + endpoint are always logged: there is no vendor specification for this API, so a tester's + trace is the only evidence available for what it actually returns. + """ + if not isinstance(payload, dict): + return payload + redact_keys = set(HANCHU_DEBUG_REDACT_KEYS) + if path in (HANCHU_ENDPOINTS["login"], HANCHU_ENDPOINTS["refresh"]): + redact_keys.add("data") + return {key: ("***" if key in redact_keys else value) for key, value in payload.items()} + + def debug_api(self, direction, what, payload=None): + """Trace one API request or response while api_debug is on. + + `what` is the endpoint path, and is passed to redact() as well as logged: the login and + refresh responses carry the token in `data`, so redaction depends on which path this is. + """ + if not self.api_debug: + return + if payload is None: + self.log("Info: Hanchu API {} {}".format(direction, what)) + return + try: + rendered = json.dumps(self.redact(payload, path=what), default=str)[:2000] + except (TypeError, ValueError): + rendered = str(payload)[:2000] + self.log("Info: Hanchu API {} {} {}".format(direction, what, rendered)) + + async def _post(self, endpoint_key, body=None, anonymous=False): + """Perform one API call, returning (ok, data). + + `ok` is the envelope's own success flag, not merely "no exception was raised". A + body-level failure and a transport failure are both False, but only the body-level one + stamps last_api_error, because only it carries the API's own reason. + + A 401 - which this API answers as HTTP 200 carrying code 401, as well as by status - is + handled here exactly once per call: the token is refreshed (or a fresh login performed) + and the request is retried a single time. That retry is earned by the re-auth and is + independent of the transport attempt budget, so an expiry landing on the last transport + attempt is still recovered rather than discarded. + + Never raises, so every caller fails closed. + """ + path = HANCHU_ENDPOINTS[endpoint_key] + url = "{}{}".format(HANCHU_BASE_URL, path) + timeout = aiohttp.ClientTimeout(total=HANCHU_TIMEOUT) + auth_retried = False + attempt = 0 + + # A while loop rather than for-in-range: `attempt` counts transport failures only. The + # re-auth branch deliberately does not touch it, and `auth_retried` caps that to one extra + # pass, so total work stays bounded while an expiry never costs the call. + while attempt < HANCHU_RETRIES: + self.debug_api("request", path, body if body is not None else {}) + try: + session = self._http_session(timeout) + async with session.post(url, headers=self._headers(), json=body if body is not None else {}) as response: + status = response.status + payload = await response.json(content_type=None) if status in (200, HANCHU_CODE_UNAUTHORISED) else None + # Deliberately broad: the docstring promises this never raises, so every caller can fail + # closed. A narrower tuple let a bare OSError from the socket layer escape run(). + except Exception as error: + attempt += 1 + if attempt >= HANCHU_RETRIES: + self.log("Warn: Hanchu {} transport failure: {}".format(path, error)) + return False, None + await asyncio.sleep(2 * attempt) + continue + + if status != 200 and status != HANCHU_CODE_UNAUTHORISED: + self.log("Warn: Hanchu {} returned HTTP {}".format(path, status)) + attempt += 1 + if attempt >= HANCHU_RETRIES: + return False, None + await asyncio.sleep(2 * attempt) + continue + + self.debug_api("response", path, payload if isinstance(payload, dict) else {"data": payload}) + if not isinstance(payload, dict): + self.log("Warn: Hanchu {} returned a non-object body".format(path)) + return False, None + + code = payload.get("code") + # An expired token shows up either as the HTTP status or as code 401 inside a 200. + if (status == HANCHU_CODE_UNAUTHORISED or code == HANCHU_CODE_UNAUTHORISED) and not anonymous and not auth_retried: + auth_retried = True + self.log("Info: Hanchu {} reported an expired session, re-authenticating".format(path)) + if await self.reauthenticate(): + continue + return False, None + + if payload.get("success") or (code == HANCHU_CODE_OK and endpoint_key in HANCHU_SUCCESS_BY_CODE_ENDPOINTS): + return True, payload.get("data") + message = payload.get("msg") + self.last_api_error = str(message) if message is not None else "" + if code == HANCHU_CODE_FAILED: + self.log("Warn: Hanchu {} was rejected by the device: {}".format(path, self.last_api_error)) + else: + self.log("Warn: Hanchu {} was unsuccessful (code {}): {}".format(path, code, self.last_api_error)) + return False, payload.get("data") + + return False, None + + def _http_session(self, timeout): + """Return the open HTTP session, opening one if there is none. + + Reused only when it positively reports itself open (closed is False): a session that has been + closed, or anything that is not a real session, is replaced rather than trusted. + """ + if self._session is None or getattr(self._session, "closed", True) is not False: + self._session = aiohttp.ClientSession(timeout=timeout) + return self._session + + async def close_session(self): + """Close the HTTP session if one is open. Safe to call more than once.""" + session, self._session = self._session, None + if session is not None and getattr(session, "closed", True) is False: + try: + await session.close() + except Exception as error: + self.log("Warn: Hanchu could not close its HTTP session: {}".format(error)) + + # ------------------------------------------------------------------ + # Auth + # ------------------------------------------------------------------ + + def token_expired(self): + """Return True when the held token is old enough to be refreshed proactively. + + The real server-side lifetime is unknown; the interval is the reference integration's own + safety margin rather than a documented figure, which is why a reactive 401 path exists too. + """ + if not self._token: + return True + return (time.time() - self._token_time) >= HANCHU_TOKEN_REFRESH_SECONDS + + async def fetch_token(self): + """Log in with the account credentials and store the session token. True on success. + + The login body carries the password in PLAINTEXT over TLS, which is what the maintained + reference integration does. A third integration RSA-encrypts it against a different + endpoint; that path is not used, and the disagreement is recorded in hanchu_const.py. + """ + if not self.account or not self.password: + return False + ok, data = await self._post("login", body={"account": self.account, "pwd": self.password}, anonymous=True) + # The token arrives as a bare string in `data`, not as an object with a token field. + token = data if isinstance(data, str) and data else "" + if not ok or not token: + self.log("Warn: Hanchu login failed; check hanchu_account and hanchu_password - they are the e-mail and password you use in the Hanchu app{}".format(" ({})".format(self.last_api_error) if self.last_api_error else "")) + return False + self._token = token + self._token_time = time.time() + self.log("Info: Hanchu login succeeded") + return True + + async def refresh_token(self): + """Exchange the held token for a fresh one, falling back to a full login. True on success. + + A rejected refresh is NOT fatal here, unlike in the reference integration: Predbat holds + the account credentials, so it can simply log in again instead of asking the user to + re-authenticate. The suppression window stops a burst of expiries becoming a refresh storm. + """ + if not self._token: + return await self.fetch_token() + if (time.time() - self._last_refresh_attempt) < HANCHU_REFRESH_MIN_INTERVAL: + # Another path refreshed moments ago. Report whether a token is still held rather + # than spending a second call on the same expiry. + return bool(self._token) + self._last_refresh_attempt = time.time() + ok, data = await self._post("refresh", body={"token": self._token}, anonymous=True) + token = data if isinstance(data, str) and data else "" + if ok and token: + self._token = token + self._token_time = time.time() + self.log("Info: Hanchu session token refreshed") + return True + self.log("Info: Hanchu token refresh was rejected{}, logging in again".format(" (code {})".format(HANCHU_CODE_REFRESH_REJECTED) if not ok else "")) + return await self.fetch_token() + + async def reauthenticate(self): + """Recover a usable session after an auth failure. True if a retry is worth making.""" + return await self.refresh_token() + + async def ensure_token(self): + """Make sure a usable token is held, logging in or refreshing as needed. True on success.""" + if not self._token: + return await self.fetch_token() + if self.token_expired(): + return await self.refresh_token() + return True + + # ------------------------------------------------------------------ + # Discovery and telemetry + # ------------------------------------------------------------------ + + @staticmethod + def _as_float(value, default=0.0): + """Coerce an API value to float, returning default for None/'unknown'/junk.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + async def get_device_list(self): + """Discover every DTU on the account, returning its serials. + + Only devType "2" - the datalogger the control endpoints are addressed to - is kept. Sets + discovery_ok so an empty account can be told apart from a failed call: the two are + indistinguishable from the returned list alone. + """ + ok, data = await self._post("device_list") + if not ok: + self.discovery_ok = False + return list(self.device_list) + self.discovery_ok = True + wanted = [str(sn).lower() for sn in self.inverter_sn_filter] + serials = [] + for entry in data or []: + if not isinstance(entry, dict): + continue + sn = entry.get(HANCHU_DEVICE_SN_FIELD) + if not sn: + continue + dev_type = str(entry.get(HANCHU_DEVICE_TYPE_FIELD, HANCHU_DEV_TYPE_DTU)) + if dev_type != HANCHU_DEV_TYPE_DTU: + # Named rather than skipped silently, so a device Predbat recognised and passed + # over does not look identical to one it never saw. + self.log("Info: Hanchu skipping {} - it reports device type {}, and only the DTU (type {}) accepts the control endpoints".format(sn, dev_type, HANCHU_DEV_TYPE_DTU)) + continue + if wanted and str(sn).lower() not in wanted: + continue + serials.append(sn) + self.device_list = serials + return serials + + async def refresh_static(self): + """Re-discover devices. True when discovery worked. + + Branches on discovery_ok rather than on list emptiness: a failed call and an empty account + both produce an empty list but need different handling. Deliberately does NOT assign an + empty discovery result over a working device_list - this tier re-runs every eight hours, so + one transient failure must not take a working component down, and assigning the empty + result would additionally cache it and stamp the tier fresh, so a restart would restore + nothing and skip re-discovery for a full TTL. Absence of a result is not a result. + """ + previous = list(self.device_list) + serials = await self.get_device_list() + if self.discovery_ok is False: + self.device_list = previous + if previous: + self.log("Warn: Hanchu discovery failed; keeping the {} previously known device(s)".format(len(previous))) + else: + self.log("Warn: Hanchu device discovery failed; the account may still have devices") + return False + if not serials: + if self.inverter_sn_filter: + self.log("Warn: Hanchu discovery succeeded but the configured serial filter {} matched nothing on this account".format(self.inverter_sn_filter)) + else: + self.log("Warn: Hanchu this account has no DTU devices on it") + return False + self.mark_refreshed("static") + await self.save_static() + return True + + def tier_expired(self, tier, ttl_minutes): + """Return True when a refresh tier is due, or has never run.""" + age = self._tier_refreshed.get(tier) + if age is None: + return True + return (time.time() - age) >= (ttl_minutes * 60) + + def mark_refreshed(self, tier): + """Start a tier's clock. Called ONLY on a successful refresh. + + Marking unconditionally would defeat the first-cycle checks in run(): a retry after a + deferred startup would find the tier "fresh", skip the poll entirely and then run + automatic_config() with no data after all - the very thing those checks exist to prevent. + """ + self._tier_refreshed[tier] = time.time() + + def _apply_status_payload(self, sn, payload): + """Map one getDeviceStatus object into device_values and device_detail. + + Returns False without a battery SoC, which is the one field Predbat cannot plan without. + Power fields are normalised to watts through their own sibling unit string where the API + provides one, and the two whose sign is opposite to Predbat's convention are negated here + rather than at publish time, so everything downstream reads one convention. + """ + if not isinstance(payload, dict) or payload.get(HANCHU_TELEMETRY["soc"]) is None: + return False + values = {} + for leaf, field in HANCHU_TELEMETRY.items(): + raw = payload.get(field) + if raw is None: + continue + if leaf == "soc": + values[leaf] = round(self._as_float(raw) * HANCHU_SOC_SCALE, 1) + continue + watts = scale_to_watts(raw, payload.get(HANCHU_TELEMETRY_UNIT_FIELD.get(leaf))) + if watts is None: + continue + values[leaf] = -watts if leaf in HANCHU_TELEMETRY_NEGATE else watts + # AC-coupled solar is only in the bypass meter (pvTtPwr reads 0), so it is added in where the + # unit says it is AC-coupled. The switch decides, not the reading: a bypass figure on a unit + # that reports no bypass meter is not trusted. See HANCHU_AC_COUPLED_FIELD. + if self._as_float(payload.get(HANCHU_AC_COUPLED_FIELD), 0) == 1 and "ac_pv_power" in values: + values["pv_power"] = round(values.get("pv_power", 0.0) + values["ac_pv_power"], 1) + self.device_values[sn] = values + + detail = self.device_detail.setdefault(sn, {}) + capacity = self._as_float(payload.get(HANCHU_CAPACITY_FIELD), 0.0) + if capacity > 0: + detail["capacity_kwh"] = capacity + station = payload.get(HANCHU_STATION_FIELD) + if station: + detail["station_id"] = str(station) + generator = payload.get(HANCHU_GENERATOR_FIELD) + if generator is not None: + detail["has_generator"] = str(generator).strip().lower() in ("true", "1") + self._apply_device_status(sn, payload.get(HANCHU_DEVICE_STATUS_FIELD)) + return True + + def _apply_device_status(self, sn, status): + """Record the datalogger's devStatus, saying so once when it goes offline and once when it returns. + + Offline means the cloud is serving the last readings it had and a write cannot reach the + inverter. Pending (99) and a missing field are unknown, not offline. + """ + if status is None: + return + code = int(self._as_float(status, -1)) + self.device_detail.setdefault(sn, {})["status"] = HANCHU_DEVICE_STATUSES.get(code, str(status)) + online = False if code == HANCHU_DEVICE_STATUS_OFFLINE else (True if code == 1 else None) + previous = self.device_online.get(sn) + if online is False and previous is not False: + self.log("Warn: Hanchu {} is offline (devStatus {}) - its readings are stale and Predbat's settings cannot reach it until it reconnects".format(sn, code)) + elif online is not False and previous is False: + self.log("Info: Hanchu {} is back online".format(sn)) + self.device_online[sn] = online + + async def refresh_power(self): + """Poll live telemetry for every device. True when at least one reported a SoC.""" + got_any = False + for sn in list(self.device_list): + ok, data = await self._post("device_status", body={"sn": sn}) + if ok and self._apply_status_payload(sn, data): + got_any = True + else: + self.log("Warn: Hanchu {} returned no usable live telemetry".format(sn)) + if self.api_delay: + await asyncio.sleep(self.api_delay) + if got_any: + self.mark_refreshed("power") + return got_any + + async def refresh_energy(self): + """Poll the daily energy counters for every device. True when at least one reported.""" + got_any = False + for sn in list(self.device_list): + ok, data = await self._post("device_statistics", body={"sn": sn}) + if not ok or not isinstance(data, dict): + continue + energy = {} + for leaf, field in HANCHU_ENERGY.items(): + if data.get(field) is not None: + energy[leaf] = round(self._as_float(data.get(field)), 3) + if energy: + self.device_energy[sn] = energy + got_any = True + if self.api_delay: + await asyncio.sleep(self.api_delay) + if got_any: + self.mark_refreshed("energy") + return got_any + + def _parse_menu_ranges(self, payload): + """Extract per-device parameter ranges from a menu response, as {key: (min, max)}. + + The menu is the phone app's own settings screen: a section of GROUPS, each a list of items, + each item naming the control key it edits and the bounds the app enforces for it. Only keys + this component has a built-in default for are taken, so an unexpected item cannot introduce + a range for something Predbat never writes. An item whose bounds do not parse is skipped + rather than defaulting, because a wrong range silently clamps a good plan. + """ + ranges = {} + for item in self._menu_items(payload): + key = item.get(HANCHU_MENU_SIGNAL) + if key not in HANCHU_RANGES: + continue + try: + low = int(float(item[HANCHU_MENU_MIN])) + high = int(float(item[HANCHU_MENU_MAX])) + except (KeyError, TypeError, ValueError): + continue + if high > low: + ranges[key] = (low, high) + return ranges + + @staticmethod + def _menu_items(payload): + """Yield every item dict in a menu response's energy section, flattening its groups. + + _post hands over the envelope's inner data, which holds the section directly. A whole + envelope, still wrapped in its own data, is accepted too. + """ + section = payload if isinstance(payload, dict) else {} + if HANCHU_MENU_SECTION not in section and isinstance(section.get(HANCHU_MENU_DATA), dict): + section = section[HANCHU_MENU_DATA] + energy = section.get(HANCHU_MENU_SECTION) + if not isinstance(energy, dict): + return + for group in energy.get(HANCHU_MENU_ITEMS) or []: + for item in group if isinstance(group, list) else []: + if isinstance(item, dict): + yield item + + def _parse_menu_slot_model(self, payload): + """Work out which time-slot format a device's menu describes, returning (model, work_modes). + + "slots" when an item edits TCT_START_1 - the format this component writes - with the work modes + its listener shows it in (empty when it names none). "array" when the menu has the other + format's period items or FLAG_ENABLE_CYCLE and no TCT_ item. "unknown" otherwise: a menu that + shows neither is not proof the device cannot run the slots, so it does not block control. + """ + has_array = False + for item in self._menu_items(payload): + signal = str(item.get(HANCHU_MENU_SIGNAL) or item.get(HANCHU_MENU_CODE) or "") + signals = [part.strip() for part in signal.split(",")] + if HANCHU_KEY_CHARGE_START.format(1) in signals: + listener = item.get(HANCHU_MENU_LISTENER) or {} + show = str(listener.get(HANCHU_MENU_LISTENER_SHOW) or "") if isinstance(listener, dict) else "" + modes = [int(part) for part in (piece.strip() for piece in show.split(",")) if part.isdigit()] + return "slots", modes + if str(item.get(HANCHU_MENU_TYPE)) in HANCHU_MENU_ARRAY_SLOT_TYPES or HANCHU_MENU_ENABLE_CYCLE in signals: + has_array = True + return ("array" if has_array else "unknown"), [] + + def _apply_slot_model(self, sn, model, modes): + """Record a device's slot format and slot work modes, saying once what was found when it matters.""" + self.slot_model[sn] = model + self.slot_work_modes[sn] = list(modes) + if model == "array" and (sn, "array") not in self._slot_notes_logged: + self._slot_notes_logged.add((sn, "array")) + self.log("Warn: Hanchu {} uses a different time-slot format (array-valued periods with FLAG_ENABLE_CYCLE), which Predbat cannot drive yet - it will be monitored but not controlled".format(sn)) + elif model == "unknown" and (sn, "unknown") not in self._slot_notes_logged: + self._slot_notes_logged.add((sn, "unknown")) + self.log("Info: Hanchu {}'s settings menu shows no time slots at all; Predbat will still write the TCT_/TDT_ slots - check them in the Hanchu app".format(sn)) + + def slot_work_mode(self, sn): + """Return the work mode to write with a window: User-defined, unless the menu runs the slots only in other modes. + + The menu's listener on the slot items names the work modes they are shown in. Where it names + modes and User-defined is not among them, the first one it names is used instead, and that is + logged once - it would be the first device seen to do so. + """ + modes = self.slot_work_modes.get(sn) or [] + if not modes or HANCHU_WORK_MODE_FOR_SCHEDULE in modes: + return HANCHU_WORK_MODE_FOR_SCHEDULE + if (sn, "mode") not in self._slot_notes_logged: + self._slot_notes_logged.add((sn, "mode")) + self.log("Warn: Hanchu {}'s menu runs its time slots in work mode {} rather than User-defined ({}), so work mode {} is written with a window".format(sn, modes[0], HANCHU_WORK_MODE_FOR_SCHEDULE, modes[0])) + return modes[0] + + def ranges_for(self, sn): + """Return the parameter ranges to clamp against for one device. + + The menu's own bounds where it reported them, and the built-in defaults for everything + else. Built per call rather than cached as a merged dict so a later menu poll cannot leave + a stale entry behind. + + A power limit's ceiling is raised to any higher value the device already holds. HARDWARE + (batpred#5305): the menu's 0-5000 W power range is one entry shared by 2-10 kW models, and a + HESS-HY-S-6.0K (nameplate 6000 W) holds DSCHG_PWR_LMT 6000 - a value the device holds is one + it accepts, and taking the menu's word would cap every discharge below the inverter's rating. + """ + merged = dict(HANCHU_RANGES) + merged.update(self.device_ranges.get(sn) or {}) + settings = self.device_settings.get(sn) or {} + for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER): + held = self._as_float(settings.get(key), 0.0) + low, high = merged[key] + if held > high: + merged[key] = (low, int(held)) + if (sn, "held", key) not in self._clamp_logged: + self._clamp_logged.add((sn, "held", key)) + self.log("Info: Hanchu {} holds {} {:.0f} W, above the menu's {} W maximum (a range the menu shares across models), so {:.0f} W is used as its limit".format(sn, key, held, high, held)) + return merged + + def _warn_if_default_power(self, sn, ranges): + """Warn once when no power bounds are held for a device, so the 5 kW default applies. + + Hanchu units come in 3.68, 5, 6 and 10 kW, so the default is wrong for most of them. Called + after every menu attempt, failed ones included - a failed menu leaves the default in force + just as surely as one that answered without bounds. Said once per device per session. + """ + missing = [key for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER) if key not in ranges] + if not missing or sn in self.warned_default_power: + return + self.warned_default_power.add(sn) + self.log( + "Warn: Hanchu {} did not report its power limits ({}), so the {} W default is in use. Set hanchu_battery_rate_max and hanchu_inverter_limit if that is not your unit's rating".format( + sn, ", ".join(missing), HANCHU_RANGES[HANCHU_KEY_CHARGE_POWER][1] + ) + ) + + async def refresh_config(self): + """Read each device's parameter ranges and current control values. True when any answered. + + Both come from one call each. The control read is a SINGLE iotGet carrying every key, for + the same reason the write is a single iotSet: nineteen separate reads a cycle against an + API with no documented budget is exactly what the reference integration's batching exists + to avoid. + """ + got_any = False + for sn in list(self.device_list): + ok, menu = await self._post("menu", body={"sn": sn}) + if ok: + ranges = self._parse_menu_ranges(menu) + if ranges: + self.device_ranges[sn] = ranges + self._apply_slot_model(sn, *self._parse_menu_slot_model(menu)) + # Warned on a failed menu too: with no ranges held, the 5 kW default is just as much + # in force. A device whose ranges were read on an earlier cycle is not warned about. + self._warn_if_default_power(sn, self.device_ranges.get(sn) or {}) + if self.api_delay: + await asyncio.sleep(self.api_delay) + ok, data = await self._post("iot_get", body={"sn": sn, "devType": HANCHU_DEV_TYPE_DTU, "keys": list(HANCHU_CONTROL_KEYS)}) + if ok and isinstance(data, dict): + self.device_settings[sn] = dict(data) + got_any = True + if self.api_delay: + await asyncio.sleep(self.api_delay) + if got_any: + self.mark_refreshed("config") + await self.save_config() + return got_any + + def battery_capacity(self, sn): + """Return the usable battery capacity in kWh, or 0.0 when the device has not reported it.""" + return self._as_float(self.device_detail.get(sn, {}).get("capacity_kwh"), 0.0) + + def battery_rate_max(self, sn): + """Return the battery charge/discharge power limit in watts. + + Taken from the CHARGE POWER key's upper bound - the menu's, or a higher value the device + holds (see ranges_for) - the only power ceiling this API exposes. Leaving battery_rate_max unmapped is NOT neutral: + inverter.py then uses a hard-coded 2600 W silently on every plan, and makes it the + governing term in the charge rate calculation, so an absent value is worse than this one. + The user's override wins where they know the pack's real limit. + """ + if self.battery_rate_max_override > 0: + return self.battery_rate_max_override + return float(self.ranges_for(sn).get(HANCHU_KEY_CHARGE_POWER, (0, 0))[1]) + + def inverter_limit(self, sn): + """Return the inverter's nominal AC power in watts. + + The API reports no inverter rating, so the discharge power ceiling stands in for it: it is + the largest AC figure the device will accept, raised to a higher value the device holds (see + ranges_for). HARDWARE (batpred#5305): on a HESS-HY-S-6.0K that gives the nameplate 6000 W. + Deliberately the DISCHARGE bound rather than the charge one, because that is the direction + the inverter's AC rating actually limits. The user's override wins. + """ + if self.inverter_limit_override > 0: + return self.inverter_limit_override + return float(self.ranges_for(sn).get(HANCHU_KEY_DISCHARGE_POWER, (0, 0))[1]) + + def reserve_max(self, devices=None): + """Return the highest reserve every device can hold, in percent - the discharge floor key's ceiling.""" + return min(int(self.ranges_for(sn).get(HANCHU_KEY_DISCHARGE_SOC, HANCHU_RANGES[HANCHU_KEY_DISCHARGE_SOC])[1]) for sn in (devices or self.device_list)) + + # ------------------------------------------------------------------ + # Publishing + # ------------------------------------------------------------------ + + def _sensor_name(self, sn, leaf): + """Return a namespaced Hanchu sensor entity id.""" + return "sensor.{}_hanchu_{}_{}".format(self.prefix, str(sn).lower(), leaf) + + def _control_name(self, domain, sn, leaf): + """Return a namespaced Hanchu control entity id.""" + return "{}.{}_hanchu_{}_{}".format(domain, self.prefix, str(sn).lower(), leaf) + + def publish_device_slots(self, sn): + """Publish the charge/discharge slot table exactly as the device reports it. + + Diagnostic only - never mapped to a Predbat arg, and never fed back into local_schedule. + The slots carry seconds since midnight on the wire, which is unreadable in a dashboard, so + each is published as HH:MM:SS with the raw value alongside. A slot the device has not + reported is skipped rather than published as zero, which would claim it is disabled when + the truth is that it was not read. + """ + settings = self.device_settings.get(sn) or {} + for direction, start_key, end_key in (("charge", HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END), ("export", HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END)): + for index in range(1, HANCHU_SLOT_COUNT + 1): + for edge, key in (("start", start_key.format(index)), ("end", end_key.format(index))): + raw = settings.get(key) + if raw is None: + continue + self.dashboard_item( + self._sensor_name(sn, "device_{}_slot_{}_{}".format(direction, index, edge)), + state=seconds_to_hhmmss(raw), + attributes={"friendly_name": "Hanchu {} Device {} Slot {} {}".format(sn, direction.title(), index, edge.title()), "raw": raw, "control_key": key, "icon": "mdi:clock-check-outline"}, + app="hanchu", + ) + + async def publish_data(self): + """Publish monitoring sensors for each device.""" + units = {"soc": "%", "battery_power": "W", "grid_power": "W", "pv_power": "W", "load_power": "W", "ac_pv_power": "W", "generator_power": "W"} + for sn in self.device_list: + values = self.device_values.get(sn, {}) + for leaf, unit in units.items(): + if leaf in values: + self.dashboard_item( + self._sensor_name(sn, leaf), + state=values[leaf], + attributes={"unit_of_measurement": unit, "friendly_name": "Hanchu {} {}".format(sn, leaf.replace("_", " ").title())}, + app="hanchu", + ) + + # Ratings are published only when actually derivable - an arg pointing at a sensor that + # never appears is worse than an absent arg the user can fill in. + capacity = self.battery_capacity(sn) + if capacity > 0: + self.dashboard_item(self._sensor_name(sn, "battery_capacity"), state=round(capacity, 3), attributes={"unit_of_measurement": "kWh", "friendly_name": "Hanchu {} Battery Capacity".format(sn)}, app="hanchu") + rate_max = self.battery_rate_max(sn) + if rate_max > 0: + self.dashboard_item(self._sensor_name(sn, "battery_rate_max"), state=round(rate_max), attributes={"unit_of_measurement": "W", "friendly_name": "Hanchu {} Battery Rate Max".format(sn)}, app="hanchu") + limit = self.inverter_limit(sn) + if limit > 0: + self.dashboard_item(self._sensor_name(sn, "inverter_limit"), state=round(limit), attributes={"unit_of_measurement": "W", "friendly_name": "Hanchu {} Inverter Limit".format(sn)}, app="hanchu") + + # Diagnostic only. The work mode is what the schedule's correctness depends on most - + # see HANCHU_WORK_MODE_FOR_SCHEDULE - so a tester needs to be able to see it. + mode = self.device_settings.get(sn, {}).get(HANCHU_KEY_WORK_MODE) + if mode is not None: + self.dashboard_item( + self._sensor_name(sn, "work_mode"), + state=HANCHU_WORK_MODES.get(int(self._as_float(mode, 0)), str(mode)), + attributes={"friendly_name": "Hanchu {} Work Mode".format(sn), "raw": mode}, + app="hanchu", + ) + station = self.device_detail.get(sn, {}).get("station_id") + if station: + self.dashboard_item(self._sensor_name(sn, "station_id"), state=station, attributes={"friendly_name": "Hanchu {} Station".format(sn)}, app="hanchu") + status = self.device_detail.get(sn, {}).get("status") + if status: + self.dashboard_item(self._sensor_name(sn, "status"), state=status, attributes={"friendly_name": "Hanchu {} Status".format(sn), "icon": "mdi:check-network-outline"}, app="hanchu") + + # The slot table as the DEVICE reports it, alongside what Predbat asked for. This is + # the single most useful diagnostic on an undocumented API: it is what distinguishes + # "the window was never written" from "the window was written and the inverter ignored + # it", which is exactly the failure mode the inferred work mode would produce. Read + # from the config tier's own iotGet, so it costs no extra call. + self.publish_device_slots(sn) + + # Daily energy counters feed Predbat's load/import/export learning. They reset at + # midnight; minute_data/clean_incrementing_reverse absorbs that. + for leaf, value in self.device_energy.get(sn, {}).items(): + self.dashboard_item( + self._sensor_name(sn, leaf), + state=value, + attributes={"unit_of_measurement": "kWh", "device_class": "energy", "state_class": "measurement", "friendly_name": "Hanchu {} {}".format(sn, leaf.replace("_", " ").title())}, + app="hanchu", + ) + + async def automatic_config(self): + """Register every discovered device as a HanchuCloud Predbat inverter.""" + devices = list(self.device_list) + if not devices: + self.log("Warn: Hanchu automatic_config found no devices") + return + self.set_arg_auto("inverter_type", ["HanchuCloud" for _ in devices]) + self.set_arg_auto("num_inverters", len(devices)) + self.set_arg_auto("soc_percent", [self._sensor_name(sn, "soc") for sn in devices]) + self.set_arg_auto("battery_power", [self._sensor_name(sn, "battery_power") for sn in devices]) + self.set_arg_auto("grid_power", [self._sensor_name(sn, "grid_power") for sn in devices]) + self.set_arg_auto("load_power", [self._sensor_name(sn, "load_power") for sn in devices]) + if not self.automatic_ignore_pv: + self.set_arg_auto("pv_power", [self._sensor_name(sn, "pv_power") for sn in devices]) + + # Own the sign flags rather than leaving them to whatever else configured this install. + # base.args is shared and NOT namespaced per inverter type, so a component that + # legitimately inverts its own grid sensor leaves that key set for every inverter index, + # and a Hanchu inverter that never claims it inherits the flip - the published sensor would + # then be correct and inverter.py would negate it again, so an export would read as an + # import. All three are False because _apply_status_payload already emits Predbat's + # conventions. + for flag in ("grid_power_invert", "battery_power_invert", "load_power_invert"): + self.set_arg_auto(flag, [False for _ in devices]) + + # Only map an arg when EVERY device reports the underlying value. + for leaf in ("load_today", "import_today", "export_today", "pv_today"): + if leaf == "pv_today" and self.automatic_ignore_pv: + continue + if all(leaf in self.device_energy.get(sn, {}) for sn in devices): + self.set_arg_auto(leaf, [self._sensor_name(sn, leaf) for sn in devices]) + else: + self.log("Warn: Hanchu not every device reports {}, it must be set manually in apps.yaml".format(leaf)) + + if all(self.battery_capacity(sn) > 0 for sn in devices): + self.set_arg_auto("soc_max", [self._sensor_name(sn, "battery_capacity") for sn in devices]) + else: + self.log("Warn: Hanchu no battery capacity reported for every device, soc_max must be set manually in apps.yaml") + if all(self.battery_rate_max(sn) > 0 for sn in devices): + self.set_arg_auto("battery_rate_max", [self._sensor_name(sn, "battery_rate_max") for sn in devices]) + else: + self.log("Warn: Hanchu no battery power limit available, battery_rate_max must be set manually in apps.yaml") + if all(self.inverter_limit(sn) > 0 for sn in devices): + self.set_arg_auto("inverter_limit", [self._sensor_name(sn, "inverter_limit") for sn in devices]) + else: + self.log("Warn: Hanchu no inverter power limit available, inverter_limit must be set manually in apps.yaml") + + # Deliberately NOT auto-mapped. The discharge power bound is the inverter's own limit, not + # the site's grid connection cap, and a G98/G99-capped site can sit well below it. Nothing + # measures and reports this error back, so a guess would over-export silently. + self.log("Warn: Hanchu does not report an export power limit; set export_limit in apps.yaml if your grid connection is capped below the inverter rating, otherwise Predbat will plan exports it cannot deliver") + # battery_min_soc is deliberately NOT mapped: the discharge floor key is a field Predbat + # writes, so reading it back as the floor would be circular. + + # The reserve lands on the discharge floor key, which only goes up to 45% on this hardware. + # Predbat decides whether it can hold the battery on the reserve against inverter_reserve_max + # (execute.py, Hold Charging), so left at its default of 100 it would hold at 80% by writing + # 81 - clamped to 45 - and drain the battery to 45% before re-charging from the grid. One + # value, because inverter.py reads it without an index; the lowest device ceiling governs. + self.set_arg_auto("inverter_reserve_max", self.reserve_max(devices)) + + self.set_arg_auto("reserve", [self._control_name("number", sn, "battery_schedule_reserve") for sn in devices]) + self.set_arg_auto("charge_start_time", [self._control_name("select", sn, "battery_schedule_charge_start_time") for sn in devices]) + self.set_arg_auto("charge_end_time", [self._control_name("select", sn, "battery_schedule_charge_end_time") for sn in devices]) + self.set_arg_auto("charge_limit", [self._control_name("number", sn, "battery_schedule_charge_soc") for sn in devices]) + self.set_arg_auto("charge_rate", [self._control_name("number", sn, "battery_schedule_charge_power") for sn in devices]) + self.set_arg_auto("scheduled_charge_enable", [self._control_name("switch", sn, "battery_schedule_charge_enable") for sn in devices]) + self.set_arg_auto("discharge_start_time", [self._control_name("select", sn, "battery_schedule_export_start_time") for sn in devices]) + self.set_arg_auto("discharge_end_time", [self._control_name("select", sn, "battery_schedule_export_end_time") for sn in devices]) + self.set_arg_auto("discharge_target_soc", [self._control_name("number", sn, "battery_schedule_export_soc") for sn in devices]) + self.set_arg_auto("discharge_rate", [self._control_name("number", sn, "battery_schedule_export_power") for sn in devices]) + self.set_arg_auto("scheduled_discharge_enable", [self._control_name("switch", sn, "battery_schedule_export_enable") for sn in devices]) + self.set_arg_auto("schedule_write_button", [self._control_name("switch", sn, "battery_schedule_charge_write") for sn in devices]) + + # ------------------------------------------------------------------ + # Discovery catalogue + # ------------------------------------------------------------------ + + def _discovery_entities(self, sn): + """The settings automatic_config() binds for one device, as discovery entity descriptors. + + Each entity id is formed with the same _sensor_name()/_control_name() call + automatic_config() makes, so the two cannot drift apart. The record describes THIS device + alone: a setting automatic_config() binds only when every device reports a figure is still + described whenever this one does. pv_power and pv_today are described even under + automatic_ignore_pv, which is the user's opt-out rather than a fact about the hardware. The + three *_power_invert settings are always False, which is the descriptor's default, so none + carries invert. export_limit and battery_min_soc are never bound. + """ + entities = { + "soc_percent": {"entity_id": self._sensor_name(sn, "soc"), "access": "r", "unit": "%"}, + "battery_power": {"entity_id": self._sensor_name(sn, "battery_power"), "access": "r", "unit": "W"}, + "grid_power": {"entity_id": self._sensor_name(sn, "grid_power"), "access": "r", "unit": "W"}, + "load_power": {"entity_id": self._sensor_name(sn, "load_power"), "access": "r", "unit": "W"}, + "pv_power": {"entity_id": self._sensor_name(sn, "pv_power"), "access": "r", "unit": "W"}, + } + energy = self.device_energy.get(sn, {}) + for leaf in ("load_today", "import_today", "export_today", "pv_today"): + if leaf in energy: + entities[leaf] = {"entity_id": self._sensor_name(sn, leaf), "access": "r", "unit": "kWh"} + if self.battery_capacity(sn) > 0: + entities["soc_max"] = {"entity_id": self._sensor_name(sn, "battery_capacity"), "access": "r", "unit": "kWh"} + if self.battery_rate_max(sn) > 0: + entities["battery_rate_max"] = {"entity_id": self._sensor_name(sn, "battery_rate_max"), "access": "r", "unit": "W"} + if self.inverter_limit(sn) > 0: + entities["inverter_limit"] = {"entity_id": self._sensor_name(sn, "inverter_limit"), "access": "r", "unit": "W"} + + ranges = self.ranges_for(sn) + charge_low, charge_high = ranges.get(HANCHU_KEY_GRID_CHARGE_SOC, (0, 100)) + floor_low, floor_high = ranges.get(HANCHU_KEY_DISCHARGE_SOC, (0, 100)) + entities["reserve"] = {"entity_id": self._control_name("number", sn, "battery_schedule_reserve"), "access": "rw", "unit": "%", "min": floor_low, "max": floor_high} + for prefix, direction in (("charge", "charge"), ("discharge", "export")): + entities["{}_start_time".format(prefix)] = {"entity_id": self._control_name("select", sn, "battery_schedule_{}_start_time".format(direction)), "access": "rw", "domain": "select", "format": "HH:MM:SS"} + entities["{}_end_time".format(prefix)] = {"entity_id": self._control_name("select", sn, "battery_schedule_{}_end_time".format(direction)), "access": "rw", "domain": "select", "format": "HH:MM:SS"} + entities["scheduled_{}_enable".format(prefix)] = {"entity_id": self._control_name("switch", sn, "battery_schedule_{}_enable".format(direction)), "access": "rw", "domain": "switch"} + entities["charge_limit"] = {"entity_id": self._control_name("number", sn, "battery_schedule_charge_soc"), "access": "rw", "unit": "%", "min": charge_low, "max": charge_high} + entities["charge_rate"] = {"entity_id": self._control_name("number", sn, "battery_schedule_charge_power"), "access": "rw", "unit": "W", "step": HANCHU_POWER_STEP} + entities["discharge_target_soc"] = {"entity_id": self._control_name("number", sn, "battery_schedule_export_soc"), "access": "rw", "unit": "%", "min": floor_low, "max": floor_high} + entities["discharge_rate"] = {"entity_id": self._control_name("number", sn, "battery_schedule_export_power"), "access": "rw", "unit": "W", "step": HANCHU_POWER_STEP} + entities["schedule_write_button"] = {"entity_id": self._control_name("switch", sn, "battery_schedule_charge_write"), "access": "rw", "domain": "switch"} + return entities + + def build_discovery(self): + """Describe the discovered Hanchu devices for the discovery catalogue. + + Reads only what the poll tiers already hold, so this adds no API calls and cannot change + what the device does. Reporting is independent of self.automatic: the catalogue records the + hardware, and the report's own "automatic" flag says whether Predbat wired apps.yaml to it. + + Deliberately NOT reported: + - export_limit: the API reports no export power limit, and the discharge power bound is the + inverter rating rather than the grid connection's cap (automatic_config() warns). + - a model name or firmware version: the plain-JSON surface carries neither. The device list + entry may well have more fields, but no reference integration reads any of them, so + inventing names for them would be a guess. + - the work mode: it changes, and refresh_discovery() compares whole reports, so a changing + value would re-file the report every time it moved. + - an AC-coupled verdict. bypMeterSwitch does distinguish the two (HANCHU_AC_COUPLED_FIELD), + but it is only used to fold AC-coupled solar into pv_power and is not kept, and the + record's composition vocabulary has been confirmed against no Hanchu system. + + "solar" is claimed only once PV has actually been observed, which is why it keys off a + reported pv_power rather than off a nameplate the API never gives. + + Returns None when nothing has been discovered yet, which refresh_discovery() treats as + "nothing to report, ask again next cycle". + """ + if not self.device_list: + return None + inverters = [] + for sn in self.device_list: + values = self.device_values.get(sn, {}) + detail = self.device_detail.get(sn, {}) + functions = ["solar", "battery"] if "pv_power" in values else ["battery"] + + ratings = {} + rate_max = self.battery_rate_max(sn) + if rate_max > 0: + ratings["battery_rate_max"] = rate_max + limit = self.inverter_limit(sn) + if limit > 0: + ratings["inverter_limit"] = limit + capacity = self.battery_capacity(sn) + if capacity > 0: + ratings["soc_max"] = capacity + # Site-wide in apps.yaml, so every record carries the value automatic_config() binds. + ratings["inverter_reserve_max"] = self.reserve_max() + + account_ids = {"station": detail["station_id"]} if detail.get("station_id") else {} + + inverters.append( + inverter_record( + "hanchu:{}".format(sn), + inverter_type="HanchuCloud", + composition="direct", + functions=functions, + capabilities=dict(HANCHU_CAPABILITIES), + hardware_ids={"serial": sn}, + account_ids=account_ids, + ratings=ratings, + entities=self._discovery_entities(sn), + ) + ) + return {"automatic": self.automatic, "inverters": inverters} + + # ------------------------------------------------------------------ + # Control entities + # ------------------------------------------------------------------ + + @staticmethod + def _empty_schedule(): + """Return a fresh, disabled schedule shape - the single source of truth for its defaults. + + Used where a schedule has to be seeded from nothing: a control event arriving for a device + local_schedule has not seen yet. Kept as one helper rather than a literal repeated at each + call site, so adding or renaming a field cannot silently diverge between copies. + """ + return { + "reserve": 0, + "charge": {"enable": False, "soc": 0, "power": 0, "start": "00:00:00", "end": "00:00:00"}, + "export": {"enable": False, "soc": 0, "power": 0, "start": "00:00:00", "end": "00:00:00"}, + } + + async def publish_schedule_settings_ha(self, sn): + """Publish the charge/export schedule control entities for one device.""" + local = self.local_schedule.get(sn, {}) + ranges = self.ranges_for(sn) + charge_low, charge_high = ranges.get(HANCHU_KEY_GRID_CHARGE_SOC, (0, 100)) + floor_low, floor_high = ranges.get(HANCHU_KEY_DISCHARGE_SOC, (0, 100)) + # Each direction advertises its own ceiling: a 6 kW unit can discharge more than it charges. + power_high = {direction: int(ranges.get(key, (0, 20000))[1]) or 20000 for direction, key in (("charge", HANCHU_KEY_CHARGE_POWER), ("export", HANCHU_KEY_DISCHARGE_POWER))} + + # Deliberately NOT clamped to the device's range on publish. This entity is Predbat's + # control surface: it writes a value then reads it back to confirm (write_and_poll_value), + # so publishing anything other than what was written guarantees a mismatch and a retry + # storm. The min/max attributes advertise the range; the clamp itself happens at the API + # boundary in build_write_payload(). + self.dashboard_item( + self._control_name("number", sn, "battery_schedule_reserve"), + state=int(local.get("reserve", 0)), + attributes={"min": floor_low, "max": floor_high, "step": 1, "unit_of_measurement": "%", "friendly_name": "Hanchu {} Battery Schedule Reserve".format(sn), "icon": "mdi:gauge"}, + app="hanchu", + ) + for direction, soc_bounds in (("charge", (charge_low, charge_high)), ("export", (floor_low, floor_high))): + window = local.get(direction, {}) + # HH:MM:SS to match INVERTER_DEF charge_time_format. Any other value makes inverter.py + # replace these entities with its own dummies and the window never reaches here. + self.dashboard_item( + self._control_name("select", sn, "battery_schedule_{}_start_time".format(direction)), + state=window.get("start", "00:00:00"), + attributes={"friendly_name": "Hanchu {} {} Start".format(sn, direction.title()), "icon": "mdi:clock-outline"}, + app="hanchu", + ) + self.dashboard_item( + self._control_name("select", sn, "battery_schedule_{}_end_time".format(direction)), + state=window.get("end", "00:00:00"), + attributes={"friendly_name": "Hanchu {} {} End".format(sn, direction.title()), "icon": "mdi:clock-outline"}, + app="hanchu", + ) + self.dashboard_item( + self._control_name("number", sn, "battery_schedule_{}_soc".format(direction)), + state=int(window.get("soc", 0)), + attributes={"min": soc_bounds[0], "max": soc_bounds[1], "step": 1, "unit_of_measurement": "%", "friendly_name": "Hanchu {} {} SoC".format(sn, direction.title()), "icon": "mdi:gauge"}, + app="hanchu", + ) + self.dashboard_item( + self._control_name("number", sn, "battery_schedule_{}_power".format(direction)), + state=int(window.get("power", 0)), + attributes={"min": 0, "max": power_high[direction], "step": HANCHU_POWER_STEP, "unit_of_measurement": "W", "friendly_name": "Hanchu {} {} Power".format(sn, direction.title()), "icon": "mdi:flash"}, + app="hanchu", + ) + self.dashboard_item( + self._control_name("switch", sn, "battery_schedule_{}_enable".format(direction)), + state="on" if window.get("enable") else "off", + attributes={"friendly_name": "Hanchu {} {} Enable".format(sn, direction.title()), "icon": "mdi:check-circle-outline"}, + app="hanchu", + ) + self.dashboard_item(self._control_name("switch", sn, "battery_schedule_charge_write"), state="off", attributes={"friendly_name": "Hanchu {} Schedule Write".format(sn), "icon": "mdi:content-save"}, app="hanchu") + + async def get_schedule_settings_ha(self, sn): + """Read the control entities into the schedule shape the payload builder consumes. + + Numeric casts route through _as_float so an entity legitimately reporting + "unknown"/"unavailable" - right after a Home Assistant restart, before Predbat republishes - + falls back to 0 rather than raising and killing the control loop. + """ + schedule = {"reserve": int(self._as_float(self.get_state_wrapper(self._control_name("number", sn, "battery_schedule_reserve"), default=0), 0))} + for direction in ("charge", "export"): + schedule[direction] = { + "enable": self.get_state_wrapper(self._control_name("switch", sn, "battery_schedule_{}_enable".format(direction)), default="off") == "on", + "start": self.get_state_wrapper(self._control_name("select", sn, "battery_schedule_{}_start_time".format(direction)), default="00:00:00"), + "end": self.get_state_wrapper(self._control_name("select", sn, "battery_schedule_{}_end_time".format(direction)), default="00:00:00"), + "soc": int(self._as_float(self.get_state_wrapper(self._control_name("number", sn, "battery_schedule_{}_soc".format(direction)), default=0), 0)), + "power": int(self._as_float(self.get_state_wrapper(self._control_name("number", sn, "battery_schedule_{}_power".format(direction)), default=0), 0)), + } + self.local_schedule[sn] = schedule + return schedule + + def _sn_from_entity(self, entity_id): + """Extract the serial from a Hanchu entity id, or None if unresolvable. + + Entity ids are always {domain}.{prefix}_hanchu_{sn}_{leaf}, so the serial is always + followed by "_". Matching sn + "_" rather than a bare prefix keeps prefix-colliding serials + apart - an entity for HC701 must never route to HC70, which would send a control write to + the wrong inverter. + """ + text = str(entity_id).lower() + for sn in self.device_list: + if "_hanchu_{}_".format(str(sn).lower()) in text: + return sn + return None + + @staticmethod + def _to_bool(value, current=False): + """Coerce a switch service or state to a boolean, keeping current when unknown.""" + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in ("turn_on", "on", "true", "1"): + return True + if text in ("turn_off", "off", "false", "0"): + return False + if text == "toggle": + return not current + return current + + def update_local_schedule(self, sn, entity_id, value): + """Apply one control-entity change to the locally held schedule.""" + schedule = self.local_schedule.setdefault(sn, self._empty_schedule()) + leaf = str(entity_id).split("_hanchu_{}_".format(str(sn).lower()), 1)[-1] + if leaf == "battery_schedule_reserve": + schedule["reserve"] = int(self._as_float(value, 0)) + return + for direction in ("charge", "export"): + prefix = "battery_schedule_{}_".format(direction) + if not leaf.startswith(prefix): + continue + field = leaf[len(prefix) :] + window = schedule.setdefault(direction, {}) + if field in ("start_time", "end_time"): + window[field.replace("_time", "")] = str(value) + elif field in ("soc", "power"): + window[field] = int(self._as_float(value, 0)) + elif field == "enable": + window["enable"] = self._to_bool(value, window.get("enable", False)) + return + + async def select_event(self, entity_id, value): + """Handle a select entity change.""" + await self._handle_control_event(entity_id, value) + + async def number_event(self, entity_id, value): + """Handle a number entity value change.""" + await self._handle_control_event(entity_id, value) + + async def switch_event(self, entity_id, service): + """Handle a switch entity service call.""" + await self._handle_control_event(entity_id, service) + + async def _handle_control_event(self, entity_id, value): + """Route one control-entity event to the right device and apply it.""" + sn = self._sn_from_entity(entity_id) + if not sn: + self.log("Warn: Hanchu could not resolve a device for {}".format(entity_id)) + return + # The write button is NOT forced. Predbat presses this on every cycle as its normal "apply + # the schedule" action (INVERTER_DEF time_button_press), not only when the plan actually + # changed, so force=True here would bypass the change-detection gate on every single cycle + # and turn one press into one write. DEYE hit exactly this: 40 presses produced 36 + # byte-identical control orders over two hours on a live site. Do not reintroduce it. + if str(entity_id).endswith("battery_schedule_charge_write"): + if self._to_bool(value): + # Predbat is now actively driving this device, so the reconcile loop may re-apply + # from here on. Marked on the press itself rather than on a successful write: a + # failed write still means Predbat owns this inverter and the next tick should retry. + self.control_active.add(sn) + await self.apply_schedule(sn) + return + self.update_local_schedule(sn, entity_id, value) + await self.publish_schedule_settings_ha(sn) + + # ------------------------------------------------------------------ + # Payload building + # ------------------------------------------------------------------ + + def _clamp(self, sn, key, value, ranges=None): + """Clamp one control value into its key's range, logging the first time it has to move. + + Logged once per (device, key) rather than every cycle: the SoC limits are narrower than + Predbat's, so a perfectly ordinary plan can sit outside them for hours, and repeating the + message every tick would bury everything else. Silently sending an out-of-range value is + not an option - it would either be rejected or ignored, and both look exactly like a window + that was written and never ran. + """ + bounded, clamped = clamp_range(key, value, ranges or self.ranges_for(sn)) + if clamped and (sn, key) not in self._clamp_logged: + self._clamp_logged.add((sn, key)) + low, high = (ranges or self.ranges_for(sn)).get(key, (bounded, bounded)) + self.log("Warn: Hanchu {} cannot be set to {} on {} - this hardware only accepts {}-{}, so {} was sent instead. Predbat's plan will be limited accordingly.".format(key, value, sn, low, high, bounded)) + return bounded + + def _window_active_now(self, window): + """Return whether a stored window covers Predbat's current minute. + + The discharge floor key is a single device-wide value, not a per-window one, so it has to + carry the export target while an export window is running and the reserve at every other + moment. That makes the payload time-aware, which is why the reconcile loop exists and why + it has to respect read-only mode. + """ + if not window.get("enable"): + return False + start = hhmmss_to_seconds(window.get("start", "00:00:00")) // 60 + end_text = str(window.get("end", "00:00:00")) + parts = end_text.split(":") + try: + end = int(parts[0]) * 60 + (int(parts[1]) if len(parts) > 1 else 0) + except (TypeError, ValueError): + return False + if end <= start: + return False + return start <= self.minutes_now < end + + def _slot_pairs(self, sn, direction, window): + """Return the up-to-three (start, end) second pairs for one direction's slot table. + + Predbat drives one window per direction; this device has three slots per direction. Slot 1 + carries the window, slot 2 carries whatever runs past midnight (INVERTER_DEF sets + can_span_midnight False so Predbat does not ask for a wrap, but it still spells "until + midnight" as an end hour of 24), and slot 3 is always spare - written as disabled, so a leftover app slot cannot run. A window the split has + collapsed to nothing is written as disabled rather than as an undocumented wrap-around, + and that decision is logged - a silently ignored window looks written and never runs. + """ + disabled = (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED) + if not window.get("enable"): + return [disabled] * HANCHU_SLOT_COUNT + first, second = split_window_seconds(window.get("start", "00:00:00"), window.get("end", "00:00:00")) + pairs = [] + for index, pair in enumerate((first, second), start=1): + if pair == disabled: + pairs.append(disabled) + continue + if window_is_empty(pair[0], pair[1]): + if index == 1: + self.log("Info: Hanchu {} {} window {}-{} carries no usable time, so it is written as disabled rather than as an undocumented wrap-around".format(sn, direction, window.get("start"), window.get("end"))) + pairs.append(disabled) + continue + pairs.append(pair) + while len(pairs) < HANCHU_SLOT_COUNT: + pairs.append(disabled) + return pairs + + def build_write_payload(self, sn, schedule): + """Build the single batched iotSet value dict for one device. + + ONE dict, sent in ONE call. The reference integration stages changes and flushes them in a + single call specifically to avoid rate limiting, and Predbat touches up to nineteen + settings per cycle, so anything per-setting would be nineteen calls every few minutes + against an API with no documented budget. + + A COMPLETE control state, not a patch, for the keys Predbat owns: the two power limits, the + charge target (the grid-charge limit), the device-wide discharge floor, the work mode + (unless the user has turned that off) and both slot tables. Every value is clamped to the + range this device reports. The overall charge ceiling, CHG_BAT_SOC_LMT, is never written: + it caps solar charging too, so it stays as the user set it. + + A charge window and an export window that overlap are never both written - the vendor's own + settings card refuses to submit that. See _resolve_overlap(). + + A hold is written as a charge slot at zero power, because a zero discharge rate on its own + does nothing outside a slot on this hardware - see _rate_signal(). The reserve is never + written above the current SoC: the slot does the holding, and a reserve above the SoC could + make the inverter charge from the grid to reach it. + + Every one of the three charge and three discharge slots is written, and any the plan does + not use is set to 00:00-00:00 - see HANCHU_SLOT_DISABLED. User-defined mode runs all six, + so a slot left over from the Hanchu app would otherwise fight the plan. + """ + ranges = self.ranges_for(sn) + charge, export = self._resolve_overlap(sn, schedule.get("charge", {}), schedule.get("export", {})) + payload = {} + signal, extra_direction = self._rate_signal(sn, charge, export) + soc = self.device_values.get(sn, {}).get("soc") + + # Power. Rate 0 is how Predbat expresses freeze, and 0 is inside the accepted range, so it + # survives the clamp and is passed straight through. A hold zeroes the charge rate as well, + # because it is carried by a charge slot at zero power. + charge_power = 0 if signal == "hold" else charge.get("power", 0) + payload[HANCHU_KEY_CHARGE_POWER] = self._clamp(sn, HANCHU_KEY_CHARGE_POWER, charge_power, ranges) + payload[HANCHU_KEY_DISCHARGE_POWER] = self._clamp(sn, HANCHU_KEY_DISCHARGE_POWER, export.get("power", 0), ranges) + + # The charge target goes in the grid-charge limit, clamped to 20-100 on this hardware, so a + # plan asking for less cannot be expressed - _clamp says so once per device. + payload[HANCHU_KEY_GRID_CHARGE_SOC] = self._clamp(sn, HANCHU_KEY_GRID_CHARGE_SOC, charge.get("soc", 0), ranges) + + # The discharge floor is device-wide, so it carries the export target while an export + # window is actually running and the reserve at every other moment - the same shape every + # other cloud adapter with a single floor register uses. + floor = export.get("soc", 0) if self._window_active_now(export) else schedule.get("reserve", 0) + if soc is not None and not self._window_active_now(export): + floor = min(self._as_float(floor, 0), int(self._as_float(soc, 0))) + payload[HANCHU_KEY_DISCHARGE_SOC] = self._clamp(sn, HANCHU_KEY_DISCHARGE_SOC, floor, ranges) + + pairs = {"charge": self._slot_pairs(sn, "charge", charge), "export": self._slot_pairs(sn, "export", export)} + if extra_direction: + # The hold slot goes in slot 3, which Predbat's own window never uses. + other = "export" if extra_direction == "charge" else "charge" + extra = self._extra_slot(sn, pairs[extra_direction][:2] + pairs[other]) + if extra: + pairs[extra_direction][2] = extra + else: + signal = None + for direction, start_key, end_key in (("charge", HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END), ("export", HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END)): + for index, (start, end) in enumerate(pairs[direction], start=1): + payload[start_key.format(index)] = start + payload[end_key.format(index)] = end + + # The work mode is only asserted when there is actually a window to run, and only if the + # user has not opted out. It is the least well evidenced thing in this component (see + # HANCHU_WORK_MODE_FOR_SCHEDULE), so it is never written speculatively - with no window + # enabled the mode is the user's business. + if self.work_mode_control and (charge.get("enable") or export.get("enable") or signal): + payload[HANCHU_KEY_WORK_MODE] = self.slot_work_mode(sn) + return payload + + def _rate_signal(self, sn, charge, export): + """Read a hold out of Predbat's rates, returning (signal, direction of the slot to add). + + On an inverter with no pause mode, execute.py asks for every hold - freeze charge, hold + charging, holding for a car or for iBoost - with a discharge rate of 0, and for freeze-export + with a charge rate of 0. HARDWARE (batpred#5305): on a HESS-HY-S-6.0K neither rate does + anything outside a slot - the battery kept feeding the house at a zero discharge rate - but a + charge slot at zero power held it. So a hold becomes a charge slot at zero power. + + Freeze-export (a zero charge rate) adds no slot. Predbat's freeze-export exports surplus solar + while the battery still covers the house, and HARDWARE (batpred#5305) showed a discharge slot + at zero power stopping the battery covering the house - so no slot fits. The zero charge rate + is passed through to self-use; whether it stops the battery absorbing solar there is still to + be live-tested. If it does not, "Maximum Charge SOC" held at the current SoC would, from 50% up. + + Returns (None, None) for an export that is running (it owns the battery), for both rates + zero or both set (no signal - an unset schedule reads as zeros), and for a charge window that + is running with the battery still below its target (that is charging). A running charge + window whose target is met is a hold carried by that window, so no slot is added. + """ + if self._window_active_now(export): + return None, None + charge_zero = self._as_float(charge.get("power"), 0) == 0 + export_zero = self._as_float(export.get("power"), 0) == 0 + if charge_zero == export_zero: + return None, None + if export_zero: + if self._window_active_now(charge): + soc = self.device_values.get(sn, {}).get("soc") + if soc is not None and self._as_float(soc, 0) >= self._as_float(charge.get("soc"), 0): + return "hold", None + return None, None + return "hold", "charge" + return None, None + + def _extra_slot(self, sn, taken): + """Return the (start, end) seconds for a hold slot, or None if there is no room. + + It runs from the start of the current half hour to an hour later, so it changes - and is + written - only every 30 minutes, and if Predbat stops renewing it the inverter is back in + self-use within the hour. It stops at 23:59 rather than wrapping, and is cut short of any + enabled slot it would overlap (the vendor card refuses overlapping slots). + """ + start = (self.minutes_now - self.minutes_now % 30) * 60 + end = min(start + 3600, HANCHU_SECONDS_PER_DAY - 60) + for other_start, other_end in taken: + if (other_start, other_end) == (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED): + continue + if other_start < end and start < other_end: + if other_start > start: + end = other_start + else: + self.log("Info: Hanchu {} has no free time for a hold slot now - an enabled slot already covers it".format(sn)) + return None + if end <= start: + return None + return (start, end) + + def _resolve_overlap(self, sn, charge, export): + """Return (charge, export) with one of them disabled if their slots would overlap. + + The window running now is kept. If neither is running (or, which Predbat never asks for, + both are), the one starting sooner is kept. Windows that only touch at an edge do not overlap. + The drop is logged once per pair of windows, because a dropped window never runs. + """ + if not (charge.get("enable") and export.get("enable")): + return charge, export + disabled = (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED) + charge_pairs = [pair for pair in self._slot_pairs(sn, "charge", charge) if pair != disabled] + export_pairs = [pair for pair in self._slot_pairs(sn, "export", export) if pair != disabled] + if not any(c_start < e_end and e_start < c_end for c_start, c_end in charge_pairs for e_start, e_end in export_pairs): + return charge, export + charge_active, export_active = self._window_active_now(charge), self._window_active_now(export) + if charge_active != export_active: + keep_charge = charge_active + else: + + def minutes_until(window): + """Return minutes from now until a window starts, wrapping past midnight.""" + return (hhmmss_to_seconds(window.get("start", "00:00:00")) // 60 - self.minutes_now) % (24 * 60) + + keep_charge = minutes_until(charge) <= minutes_until(export) + dropped, kept = ("export", "charge") if keep_charge else ("charge", "export") + note = (sn, "overlap", charge.get("start"), charge.get("end"), export.get("start"), export.get("end")) + if note not in self._clamp_logged: + self._clamp_logged.add(note) + self.log( + "Warn: Hanchu {} charge window {}-{} and export window {}-{} overlap, which the device does not accept, so only the {} window is written and the {} window is dropped".format( + sn, charge.get("start"), charge.get("end"), export.get("start"), export.get("end"), kept, dropped + ) + ) + if keep_charge: + return charge, dict(export, enable=False) + return dict(charge, enable=False), export + + @staticmethod + def payloads_equal(first, second): + """Return True when two payloads are identical, ignoring key order.""" + if not isinstance(first, dict) or not isinstance(second, dict): + return False + return json.dumps(first, sort_keys=True, default=str) == json.dumps(second, sort_keys=True, default=str) + + @staticmethod + def rejected_keys(data): + """Return the keys one iotSet response says the device refused. + + One reference integration reads a per-key acknowledgement map in which "1" means accepted; + the other two ignore it entirely. Single-source, so its ABSENCE is never treated as a + failure - only a present map naming a key with anything other than "1" is. + """ + if not isinstance(data, dict): + return [] + ack = data.get(HANCHU_ACK_MAP) + if not isinstance(ack, dict): + return [] + return sorted(key for key, value in ack.items() if str(value) != HANCHU_ACK_OK) + + # ------------------------------------------------------------------ + # Writing + # ------------------------------------------------------------------ + + def _is_read_only(self): + """Return True when Predbat is in read-only mode and must not write to the inverter.""" + return self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" + + def _write_allowed(self, sn, force=False): + """Return True when a write for one device may go out now. + + A change arriving inside the pacing window is HELD, not dropped: _write_payload leaves the + applied-payload cache untouched, so the next eligible tick rebuilds and sends it. + """ + if force or not self.min_write_interval: + return True + last = self.last_write_time.get(sn) + if last is None: + return True + interval = self.min_write_interval + if self.write_failures.get(sn): + interval = min(interval, HANCHU_FAILED_WRITE_INTERVAL) + return (time.time() - last) >= interval + + async def _write_payload(self, sn, payload, force=False): + """Serialise every settings write through one lock, then send it. + + The equality and pacing checks run INSIDE the lock, so a caller that waited sees the write + the one before it just made and does not resend the same payload. + """ + async with self.write_lock: + return await self._write_payload_locked(sn, payload, force=force) + + async def _write_payload_locked(self, sn, payload, force=False): + """Send one batched payload if it differs from the last accepted one and pacing allows. + + Returns whether the device is now KNOWN TO MATCH this payload, not merely whether an + exception was avoided. True covers "sent and accepted" and "already matched, so nothing + needed sending" alike. False covers both a HELD change - a real difference exists, but the + pacing gate is deliberately not sending it yet - and an outright rejection; a caller must + not read either as the device matching the plan. + """ + if not payload: + return True + if not force and self.payloads_equal(self.applied_payload.get(sn), payload): + self.log("Info: Hanchu {} settings unchanged, nothing sent".format(sn)) + return True + if not self._write_allowed(sn, force=force): + self.log("Info: Hanchu {} change is held by hanchu_min_write_interval ({}s) and will be applied on the next eligible cycle".format(sn, self.min_write_interval)) + return False + + body = {"sn": sn, "devType": HANCHU_DEV_TYPE_DTU, "value": payload} + ok, data = await self._post("iot_set", body=body) + if not ok: + # One retry after a short pause, matching the reference integration's own flush + # behaviour, because a single rejected batch strands every setting in it. + self.log("Warn: Hanchu {} settings write failed ({}), retrying once in {}s".format(sn, self.last_api_error or "no reason given", self.write_retry_delay)) + if self.write_retry_delay: + await asyncio.sleep(self.write_retry_delay) + ok, data = await self._post("iot_set", body=body) + + # Stamp every attempt, success or not. A persistently rejected write must still be paced, + # or the reconcile loop re-posts it on every tick forever. The payload is deliberately NOT + # cached as applied on any failure path, so it keeps retrying - just paced, not hammering. + now = time.time() + self.last_write_time[sn] = now + if not ok: + self.log("Warn: Hanchu {} settings write was rejected after a retry: {}".format(sn, self.last_api_error or "no reason given")) + self._record_write_failure(sn) + # Saved inline so a container kill right after a write does not lose the pacing + # timestamp just stamped above and reopen the retry storm this guards against. + await self.save_control() + return False + + rejected = self.rejected_keys(data) + if rejected: + # A partial accept is NOT cached as applied: the next cycle must rebuild and resend, + # or the rejected keys would be assumed to be in force forever. + self.log("Warn: Hanchu {} accepted the write but refused {}; it will be rebuilt and resent on the next eligible cycle".format(sn, ", ".join(rejected))) + self._record_write_failure(sn) + await self.save_control() + return False + + self.applied_payload[sn] = dict(payload) + self.log("Info: Hanchu wrote {} setting(s) for {} in one call".format(len(payload), sn)) + self._record_write_success(sn) + await self.save_control() + return True + + def _record_write_failure(self, sn): + """Count a rejected write; after several in a row, tell the user once. + + A log line alone is not enough here: a rejected shorten leaves the inverter running past + the plan, and a rejected lengthen ends a window early. Both need a person to notice. + """ + count = self.write_failures.get(sn, 0) + 1 + self.write_failures[sn] = count + if count >= HANCHU_WRITE_FAILURE_NOTIFY and sn not in self.write_failure_notified: + self.write_failure_notified.add(sn) + self.non_fatal_error_occurred() + self.call_notify("Predbat: Hanchu {} has rejected {} settings writes in a row ({}). The inverter may not be following the plan - check it in the Hanchu app".format(sn, count, self.last_api_error or "no reason given")) + + def _record_write_success(self, sn): + """Clear the failure streak, and say so if the user was told about it.""" + if sn in self.write_failure_notified: + self.log("Info: Hanchu {} settings writes are being accepted again after {} failures".format(sn, self.write_failures.get(sn, 0))) + self.write_failure_notified.discard(sn) + self.write_failures.pop(sn, None) + + async def apply_schedule(self, sn, force=False): + """Build and send the locally held schedule for one device. + + Refused for a device on the array time-slot format: it would accept the TCT_/TDT_ write and + ignore it, which looks exactly like a window that ran. + """ + if not self.control_enable: + return False + if self.slot_model.get(sn) == "array": + if (sn, "array") not in self._slot_notes_logged: + self._slot_notes_logged.add((sn, "array")) + self.log("Warn: Hanchu {} uses a different time-slot format (array-valued periods with FLAG_ENABLE_CYCLE), which Predbat cannot drive yet - nothing is written to it".format(sn)) + return False + schedule = self.local_schedule.get(sn) + if not schedule: + return False + return await self._write_payload(sn, self.build_write_payload(sn, schedule), force=force) + + async def _reconcile_control(self, sn): + """Re-apply sn's schedule if Predbat already controls it, unforced. + + Gated on read-only for a specific reason. Predbat's own read-only handling covers every + write that originates from a plan, but NOT one this component initiates itself - and this + is exactly that. The payload is time-aware because the device-wide discharge floor switches + between the export target and the reserve, so a window transition changes it with no plan + change at all, and without this gate that transition would write to the inverter while + Predbat was in read-only mode. + """ + if sn not in self.control_active or self._is_read_only() or not self.control_enable: + return + try: + await self.apply_schedule(sn) + except Exception as error: + self.log("Warn: Hanchu schedule apply failed for {}: {}".format(sn, error)) + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + async def load_cache(self, name): + """Load one cache file, returning {} when absent or unreadable. + + self.storage being None is checked FIRST and returns silently: it means no Storage + component is configured, which is a permanent, by-design condition rather than a transient + fault worth warning about. Only a real failure below flags the restore as incomplete. + """ + if self.storage is None: + return {} + try: + data = await self.storage.load(HANCHU_STORAGE_MODULE, name) + except Exception as error: + self.log("Warn: Hanchu could not load cache {}: {}".format(name, error)) + self._restore_had_error = True + return {} + return data if isinstance(data, dict) else {} + + async def save_cache(self, name, data): + """Save one cache file, tolerating a storage failure.""" + if self.storage is None: + return + try: + await self.storage.save(HANCHU_STORAGE_MODULE, name, data) + except Exception as error: + self.log("Warn: Hanchu could not save cache {}: {}".format(name, error)) + + async def save_static(self): + """Persist discovery. Refuses to overwrite a good cache with an empty result. + + Writing an empty device list and stamping the tier fresh would make a restart restore + nothing and skip re-discovery for a full TTL. Absence of a result is not a result. + """ + if not self.device_list: + return + await self.save_cache(HANCHU_CACHE_STATIC, {"device_list": self.device_list, "device_detail": self.device_detail}) + + async def save_config(self): + """Persist the per-device parameter ranges and the control values last read back.""" + await self.save_cache( + HANCHU_CACHE_CONFIG, + { + "device_ranges": {sn: list(ranges.items()) for sn, ranges in self.device_ranges.items()}, + "device_settings": self.device_settings, + "slot_model": self.slot_model, + "slot_work_modes": self.slot_work_modes, + }, + ) + + async def save_control(self): + """Persist the control state that must survive a restart. + + The applied payload matters most: without it a restart re-sends a payload the device + already holds, and - worse - forgets which slots this component has written, so the rule + that only our own slots may be zeroed would be lost. The write timestamp is persisted for + the same reason, so a restart loop cannot bypass the pacing interval entirely. + """ + await self.save_cache( + HANCHU_CACHE_CONTROL, + { + "local_schedule": self.local_schedule, + "applied_payload": self.applied_payload, + "control_active": sorted(self.control_active), + "last_write_time": dict(self.last_write_time), + }, + ) + + async def restore_state(self): + """Restore every cached value so a restart does not re-learn them from scratch. + + _cache_restored is set only when nothing failed, so a transient storage outage is retried + on a later cycle rather than silently marked done with nothing restored. + """ + self._restore_had_error = False + static = await self.load_cache(HANCHU_CACHE_STATIC) + if static.get("device_list"): + self.device_list = list(static["device_list"]) + self.device_detail = dict(static.get("device_detail") or {}) + config = await self.load_cache(HANCHU_CACHE_CONFIG) + # Ranges round-trip as lists of pairs because JSON cannot carry a tuple; rebuilt as tuples + # so clamp_range sees exactly what the live path gives it. + self.device_ranges = {sn: {key: tuple(bounds) for key, bounds in (pairs or [])} for sn, pairs in (config.get("device_ranges") or {}).items()} + self.device_settings = dict(config.get("device_settings") or {}) + self.slot_model = dict(config.get("slot_model") or {}) + self.slot_work_modes = {sn: list(modes or []) for sn, modes in (config.get("slot_work_modes") or {}).items()} + control = await self.load_cache(HANCHU_CACHE_CONTROL) + self.local_schedule = dict(control.get("local_schedule") or {}) + self.applied_payload = dict(control.get("applied_payload") or {}) + self.control_active = set(control.get("control_active") or []) + self.last_write_time = dict(control.get("last_write_time") or {}) + self._cache_restored = not self._restore_had_error + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def health_message(self): + """Return a short reason this component is unhealthy, or None when it has nothing to add.""" + if not self.account or not self.password: + return "Hanchu needs hanchu_account and hanchu_password" + if not self._token: + return "Hanchu is not logged in{}".format(": {}".format(self.last_api_error) if self.last_api_error else "") + if not self.device_list: + return "Hanchu found no devices on this account" + offline = [sn for sn in self.device_list if self.device_online.get(sn) is False] + if offline: + return "Hanchu {} offline - readings are stale".format(", ".join(offline)) + unsupported = [sn for sn in self.device_list if self.slot_model.get(sn) == "array"] + if unsupported: + return "Hanchu {} uses a time-slot format Predbat cannot drive".format(", ".join(unsupported)) + return None + + async def run(self, seconds, first): + """Main component tick: refresh by tier, publish, and apply any schedule change. + + Returns True on a completed cycle, False on a failure that should hold the component in + ComponentBase's startup backoff and be retried. Deliberately explicit rather than falling + through to Python's implicit None: ComponentBase only clears its `first` flag when this + returns something truthy, so an accidental None would strand the component in the + ever-growing startup backoff forever even though every later cycle was working. + """ + if first and not self._cache_restored: + # Guarded on _cache_restored, not just `first`: a deferred startup returns False below + # and leaves ComponentBase's `first` flag set, so run() is retried with first still + # True. Without this guard a SUCCESSFUL restore would re-run on every retry, redoing + # storage reads for nothing and re-overwriting anything progressed in memory since. + await self.restore_state() + + if not self.account or not self.password: + self.log("Warn: Hanchu needs both hanchu_account and hanchu_password - the e-mail address and password you sign into the Hanchu app with") + return False + if not await self.ensure_token(): + return False + + if self.tier_expired("static", HANCHU_TTL_STATIC) or not self.device_list: + await self.refresh_static() + if not self.device_list: + self.log("Warn: Hanchu found no devices on this account") + return False + if self.tier_expired("config", HANCHU_TTL_CONFIG): + await self.refresh_config() + live_ok = True + if self.tier_expired("power", HANCHU_TTL_POWER): + live_ok = await self.refresh_power() + if self.tier_expired("energy", HANCHU_TTL_ENERGY): + await self.refresh_energy() + + for sn in list(self.device_list): + # Read the control entities EVERY tick, the first one included. Home Assistant retains + # them across a Predbat restart, so on a restart they already hold the live plan; + # seeding local_schedule from _empty_schedule() and publishing that back would + # overwrite it and cancel an in-flight charge until Predbat next replanned. + # get_schedule_settings_ha falls back to the disabled default per field, so a genuinely + # cold start still lands on exactly _empty_schedule()'s shape. + try: + await self.get_schedule_settings_ha(sn) + except Exception as error: + self.log("Warn: Hanchu schedule read failed for {}: {}".format(sn, error)) + await self._reconcile_control(sn) + # Published every tick, not just the first: this is Predbat's control surface and must + # keep reflecting local_schedule as it changes. + await self.publish_schedule_settings_ha(sn) + + await self.publish_data() + + # Filed right after this cycle's publish and BEFORE the deferred-startup return below, + # which is the branch that fires on exactly the installs whose dump most needs to say what + # hardware was found. refresh_discovery() owns the compare/retry/guard loop and never raises. + self.refresh_discovery() + + if first and not live_ok: + # Startup has not really succeeded without telemetry: automatic_config() runs on the + # first cycle ALONE, so it would map only the args backed by cached values and + # permanently skip soc_max and the energy args for the whole session. Returning False + # leaves ComponentBase's `first` flag set, so the startup path is retried on backoff. + self.log("Warn: Hanchu first telemetry poll returned nothing, deferring startup; it will be retried after a backoff") + return False + + if first and self.automatic: + await self.automatic_config() + # An offline datalogger means stale readings and writes that cannot land, so the component is + # not reported healthy while any device is offline; health_message() then says why. + if not any(self.device_online.get(sn) is False for sn in self.device_list): + self.update_success_timestamp() + return True + + async def final(self): + """Persist state on shutdown so a restart resumes without re-polling, then close the HTTP session.""" + await self.save_static() + await self.save_config() + await self.save_control() + await self.close_session() + + +# ---------------------------------------------------------------------- +# Command-line harness for live hardware +# +# python3 hanchu.py --account you@example.com read-only report (the default) +# python3 hanchu.py ... --write-test write a future window, read it back, restore +# python3 hanchu.py ... --live-window charge run a real window now, watch it, restore +# python3 hanchu.py ... --restore hanchu_snapshot_*.json put back a snapshot a test saved +# +# Nobody on the project has Hanchu hardware, so this is how a tester proves or disproves what +# hanchu_const.py marks INFERRED. The pure helpers below decide what gets written, what gets +# restored and how a run is judged, and are unit-tested (tests/test_hanchu_cli.py); the async +# drivers that talk to the real cloud are not. +# ---------------------------------------------------------------------- + +# The live tests, each named after the Predbat state it reproduces. +HANCHU_CLI_SCENARIOS = ("charge", "export", "freeze-charge", "freeze-export", "hold") + +# What each scenario should make the battery do, printed before anything is written so the tester +# knows what they are watching for. +HANCHU_CLI_EXPECTATION = { + "charge": "the battery CHARGES (battery power negative) at about the requested power", + "export": "the battery DISCHARGES (battery power positive) and the surplus is exported", + "freeze-charge": "the battery neither charges nor discharges while the house draws from the grid", + "freeze-export": "the battery does not charge, so surplus solar is exported instead", + "hold": "the battery neither charges nor discharges, because the open charge slot's target is already met", +} + +# Every key a restore may write back: everything build_write_payload() can touch. The overall charge +# ceiling is read but never written by Predbat, so a restore leaves it alone too. +HANCHU_CLI_RESTORE_KEYS = [key for key in HANCHU_CONTROL_KEYS if key != HANCHU_KEY_CHARGE_SOC] + +# apps.yaml keys --config reads, mapped to the command-line fields they stand in for. +HANCHU_CLI_CREDENTIAL_KEYS = {"account": "hanchu_account", "password": "hanchu_password", "serial": "hanchu_inverter_sn"} + +# Readings inside this many seconds of a write are ignored when judging: the cloud relays the write +# to the inverter and the inverter ramps, so the first minute shows the old behaviour. +HANCHU_CLI_SETTLE_SECONDS = 60 + +# Battery or surplus power below this many watts is treated as noise when judging a run. +HANCHU_CLI_THRESHOLD_W = 100 + + +def merge_hanchu_credentials(cli, config): + """Fill any credential not given on the command line from an apps.yaml pred_bat section. + + An explicit command-line value always wins. hanchu_inverter_sn may be a list in apps.yaml, but + the harness drives one device, so a list gives its first entry. + + Returns (merged, used): used names the apps.yaml keys actually taken, so the caller can say so. + """ + merged = dict(cli) + used = [] + for cli_name, config_key in HANCHU_CLI_CREDENTIAL_KEYS.items(): + if merged.get(cli_name): + continue + value = config.get(config_key) + if isinstance(value, list): + value = value[0] if value else None + if value: + merged[cli_name] = str(value) + used.append(config_key) + return merged, used + + +def _cli_number(value): + """Return a settings value as an int where it reads as a number, else unchanged. + + iotGet returns values as strings or floats ("3", 70.0), while build_write_payload() produces + ints, so both sides are normalised before they are compared or written back. + """ + try: + return int(float(value)) + except (TypeError, ValueError): + return value + + +def _cli_hhmm(minutes): + """Format minutes since midnight as Predbat's HH:MM:SS control-entity form.""" + return "{:02d}:{:02d}:00".format(minutes // 60, minutes % 60) + + +def build_cli_schedule(scenario, start_minutes, soc, settings, ranges, window_minutes, power, require_effect=True, target=None): + """Build the schedule a live test writes, in the shape get_schedule_settings_ha() produces. + + It goes through the component's own build_write_payload() afterwards, so the test exercises the + shipped payload path rather than a hand-built one. Each scenario mirrors what execute.py leaves + on the control entities for that state: + + - charge: a charge window from start_minutes, target 10 points above the current SoC. + - export: an export window from start_minutes, floor 10 points below the current SoC. + - freeze-charge: no window, discharge rate 0 and the reserve raised to SoC + 1 (how Predbat + holds the battery on an inverter with no pause mode). + - freeze-export: no window, charge rate 0 (how Predbat stops the battery soaking up solar). + - hold: a charge window from start_minutes whose grid-charge target is the current SoC, with no + charge or discharge power - a candidate single hold for every SoC from 20% up, where the + reserve stops at 45%. It holds only if the inverter idles in a slot whose target is met. + + The rate Predbat is not using is left at the device maximum, as Predbat does outside a window, + and a disabled charge window carries a 100% target - Predbat's demand-mode value, and the one + that cannot cap solar charging if the target turns out to be device-wide. + + target, when given, replaces the charge target or export floor - set it just past the current SoC + to watch the inverter stop a window at the target by itself. + + Raises ValueError when the scenario is unknown, the window would have under two minutes before + midnight, or (with require_effect) the battery is already where the test would move it, so the + run could only ever come back inconclusive. + + Returns (schedule, notes), notes explaining anything adjusted to fit the device. + """ + if scenario not in HANCHU_CLI_SCENARIOS: + raise ValueError("unknown scenario {!r}; choose one of {}".format(scenario, ", ".join(HANCHU_CLI_SCENARIOS))) + notes = [] + charge_low, charge_high = ranges.get(HANCHU_KEY_GRID_CHARGE_SOC, HANCHU_RANGES[HANCHU_KEY_GRID_CHARGE_SOC]) + floor_low, floor_high = ranges.get(HANCHU_KEY_DISCHARGE_SOC, HANCHU_RANGES[HANCHU_KEY_DISCHARGE_SOC]) + charge_max = int(ranges.get(HANCHU_KEY_CHARGE_POWER, HANCHU_RANGES[HANCHU_KEY_CHARGE_POWER])[1]) + discharge_max = int(ranges.get(HANCHU_KEY_DISCHARGE_POWER, HANCHU_RANGES[HANCHU_KEY_DISCHARGE_POWER])[1]) + soc = float(soc) + + reserve = _cli_number(settings.get(HANCHU_KEY_DISCHARGE_SOC)) + if not isinstance(reserve, int): + reserve = int(floor_low) + reserve = max(int(floor_low), min(int(floor_high), reserve)) + + def capped(requested, high): + """Cap a requested power at the device's range, noting it when the cap bites.""" + if requested is None: + return high + requested = max(0, int(requested)) + if requested > high: + notes.append("power capped at {} W, the most this device accepts".format(high)) + return high + return requested + + schedule = { + "reserve": reserve, + "charge": {"enable": False, "soc": int(charge_high), "power": charge_max, "start": "00:00:00", "end": "00:00:00"}, + "export": {"enable": False, "soc": reserve, "power": discharge_max, "start": "00:00:00", "end": "00:00:00"}, + } + + if scenario in ("charge", "export", "hold"): + end_minutes = start_minutes + int(window_minutes) + if end_minutes > 23 * 60 + 59: + end_minutes = 23 * 60 + 59 + notes.append("window cut at 23:59 so it does not cross midnight") + if end_minutes - start_minutes < 2: + raise ValueError("under two minutes left before midnight - run the test after 00:00") + window = {"enable": True, "start": _cli_hhmm(start_minutes), "end": _cli_hhmm(end_minutes)} + + if scenario == "charge": + if require_effect and soc >= charge_high - 1: + raise ValueError("the battery is at {:.0f}%, so a charge would show nothing - try export or freeze-charge".format(soc)) + if require_effect and target is not None and soc >= int(target): + raise ValueError("the battery is already at {:.0f}%, at or above the {}% target - set --target above it".format(soc, target)) + target = min(int(charge_high), int(soc) + 10) if target is None else int(target) + if target < charge_low: + notes.append("charge target raised to {}%, the lowest this device accepts".format(charge_low)) + target = int(charge_low) + schedule["charge"] = dict(window, soc=target, power=capped(power, charge_max)) + elif scenario == "export": + wanted = int(soc) - 10 if target is None else int(target) + floor = max(int(floor_low), min(int(floor_high), wanted)) + if floor == int(floor_high) and wanted > floor_high: + notes.append("export floor set to {}%, the highest this device accepts, so the battery may export further than asked".format(floor_high)) + # An explicit target is the tester's choice, so only a battery already at or below it is + # refused; the default floor also needs a little room to show anything. + if require_effect and soc <= floor + (0 if target is not None else 2): + raise ValueError("the battery is at {:.0f}%, too close to the {}% floor for an export to show anything - try charge or freeze-export".format(soc, floor)) + schedule["export"] = dict(window, soc=floor, power=capped(power, discharge_max)) + elif scenario == "hold": + if int(soc) < charge_low: + notes.append("target set to {}%, the lowest the grid-charge limit accepts; with no charge power the slot still cannot charge".format(charge_low)) + schedule["charge"] = dict(window, soc=max(int(charge_low), min(int(charge_high), int(soc))), power=0) + schedule["export"]["power"] = 0 + elif scenario == "freeze-charge": + # execute.py, on an inverter with no pause mode: discharge rate 0 AND the reserve raised to + # SoC + 1. HARDWARE (batpred#5305): the rate alone does not hold the battery outside a slot. + # Above the floor's ceiling Predbat will not freeze at all (inverter_reserve_max). + if require_effect and int(soc) + 1 > floor_high: + raise ValueError("the battery is at {:.0f}%, above the {}% the reserve can be set to - above that Predbat holds with its charge window instead, which --live-window hold reproduces".format(soc, floor_high)) + schedule["reserve"] = max(int(floor_low), min(int(floor_high), int(soc) + 1)) + schedule["export"]["soc"] = schedule["reserve"] + schedule["export"]["power"] = 0 + else: + schedule["charge"]["power"] = 0 + return schedule, notes + + +def cli_future_start(minutes_now, window_minutes): + """Pick a start time for the write test's window: three hours from now, never crossing midnight. + + The write test proves a write lands and reads back without moving any energy, so its window must + not cover the present, and must fit inside one day since the harness never writes a wrap-around. + """ + start = (minutes_now + 180) % (24 * 60) + if start + window_minutes > 23 * 60 + 59: + start = 60 + return start + + +def settings_mismatches(expected, actual): + """Return (key, expected, actual) for every expected setting the read-back does not match. + + Compared as numbers, since iotGet returns "3" or 70.0 where the write sent 3 or 70. A key the + read-back does not carry at all is a mismatch with actual None. + """ + mismatches = [] + for key, value in expected.items(): + got = actual.get(key) + if got is None or _cli_number(got) != _cli_number(value): + mismatches.append((key, value, got)) + return mismatches + + +def restore_payload(snapshot, keys): + """Build the write that puts keys back to their snapshot values. + + Returns (payload, missing). missing names keys the snapshot never held, which cannot be restored + and must be reported rather than silently left at the test's value. + """ + payload = {} + missing = [] + for key in keys: + if snapshot.get(key) is None: + missing.append(key) + else: + payload[key] = _cli_number(snapshot[key]) + return payload, missing + + +def save_cli_snapshot(folder, sn, settings): + """Save the settings read before a test write, so the test can be undone even if the run dies. + + Returns the file's path, which the harness prints before it writes anything. + """ + path = os.path.join(folder or ".", "hanchu_snapshot_{}_{}.json".format(sn, datetime.now().strftime("%Y%m%d_%H%M%S"))) + with open(path, "w") as handle: + json.dump({"serial": sn, "taken": datetime.now().replace(microsecond=0).isoformat(), "settings": settings}, handle, indent=2, default=str) + return path + + +def load_cli_snapshot(path): + """Load a snapshot saved by save_cli_snapshot(), returning (serial, settings). + + Raises ValueError when the file is unreadable or does not look like a snapshot. + """ + try: + with open(path, "r") as handle: + data = json.load(handle) + except (OSError, ValueError) as error: + raise ValueError("could not read snapshot {}: {}".format(path, error)) + if not isinstance(data, dict) or not data.get("serial") or not isinstance(data.get("settings"), dict): + raise ValueError("{} is not a Hanchu settings snapshot".format(path)) + return str(data["serial"]), data["settings"] + + +def judge_live_window(scenario, samples, threshold=HANCHU_CLI_THRESHOLD_W, settle_seconds=HANCHU_CLI_SETTLE_SECONDS, target=None): + """Decide whether a live test did what the scenario expects, from its telemetry samples. + + samples are dicts of Predbat-convention readings (battery negative on charge) plus "elapsed", + seconds since the write was accepted. Readings inside settle_seconds are ignored. + + A freeze is only judged on readings where it could show something: freeze-charge needs the house + to be drawing more than solar supplies, freeze-export needs a solar surplus. Without that, a held + battery and a free one look identical, and the answer is INCONCLUSIVE rather than a false PASS. + + With a target (--target) on a charge or export, the question is instead whether the inverter + stops at the target by itself: moving before the SoC reaches it, idle once it has. A run that + never reaches the target says nothing about stopping, so it is INCONCLUSIVE. + + Returns (verdict, reason), verdict one of PASS, FAIL or INCONCLUSIVE. + """ + + def reading(item, key): + """Return one reading as a float, treating a missing or junk value as zero.""" + try: + return float(item.get(key) or 0) + except (TypeError, ValueError): + return 0.0 + + settled = [item for item in samples if item.get("elapsed", 0) >= settle_seconds and item.get("battery_power") is not None] + if not settled: + return "INCONCLUSIVE", "no readings after the first {}s, while the inverter is still settling - run for longer".format(settle_seconds) + + def solar(item): + """Return the solar reading - pv_power, which already carries AC-coupled solar where there is any.""" + return reading(item, "pv_power") + + battery = [reading(item, "battery_power") for item in settled] + median = statistics.median(battery) + if target is not None and scenario in ("charge", "export"): + charging = scenario == "charge" + reached = [index for index, item in enumerate(settled) if (reading(item, "soc") >= target if charging else reading(item, "soc") <= target)] + if not reached: + return "INCONCLUSIVE", "the SoC never reached the {}% target in the window, so stopping at it was not tested - set --target closer or run longer".format(target) + after = [reading(item, "battery_power") for item in settled[reached[0] :]] + moving = sum(1 for value in after if (value <= -threshold if charging else value >= threshold)) + if moving * 2 > len(after): + return "FAIL", "the battery kept {} past the {}% target (median {:.0f} W after reaching it)".format("charging" if charging else "discharging", target, statistics.median(after)) + return "PASS", "the battery stopped at the {}% target by itself (median {:.0f} W after reaching it)".format(target, statistics.median(after)) + if scenario == "charge": + if sum(1 for value in battery if value <= -threshold) * 2 > len(battery): + return "PASS", "the battery charged at a median {:.0f} W".format(-median) + return "FAIL", "the battery did not charge - median battery power {:.0f} W (negative is charging)".format(median) + if scenario == "export": + if sum(1 for value in battery if value >= threshold) * 2 > len(battery): + return "PASS", "the battery discharged at a median {:.0f} W".format(median) + return "FAIL", "the battery did not discharge - median battery power {:.0f} W (positive is discharging)".format(median) + if scenario in ("freeze-charge", "hold"): + demand = [item for item in settled if reading(item, "load_power") - solar(item) > threshold] + if not demand: + return "INCONCLUSIVE", "solar covered the house load throughout, so a held battery and a free one look the same - retry when the house is drawing power" + draining = [reading(item, "battery_power") for item in demand] + if sum(1 for value in draining if value >= threshold) * 2 > len(draining): + return "FAIL", "the battery fed the house at a median {:.0f} W when it should have been held".format(statistics.median(draining)) + # With the house drawing more than solar supplies, any charging is coming from the grid - a + # reserve above the SoC can make an inverter do that, and it is no more a hold than draining. + if sum(1 for value in draining if value <= -threshold) * 2 > len(draining): + return "FAIL", "the battery charged from the grid at a median {:.0f} W when it should have been held".format(-statistics.median(draining)) + return "PASS", "the battery held (median {:.0f} W) while the house drew more than solar supplied".format(statistics.median(draining)) + surplus = [item for item in settled if solar(item) - reading(item, "load_power") > threshold] + if not surplus: + return "INCONCLUSIVE", "there was no solar surplus, so there was nothing for the battery to refuse - retry in sunshine" + charging = [reading(item, "battery_power") for item in surplus] + if sum(1 for value in charging if value <= -threshold) * 2 > len(charging): + return "FAIL", "the battery charged from the surplus at a median {:.0f} W when charging should have been stopped".format(-statistics.median(charging)) + return "PASS", "the battery stayed out of the surplus (median {:.0f} W)".format(statistics.median(charging)) + + +def _cli_confirm(prompt, assume_yes=False): # pragma: no cover + """Ask before writing to a real inverter, treating a closed stdin or Ctrl-C as no.""" + if assume_yes: + return True + try: + answer = input(prompt) + except (EOFError, KeyboardInterrupt): + print("\nNo input available, nothing sent.") + return False + return answer.strip().lower() == "y" + + +def _cli_refresh_clock(client): # pragma: no cover + """Move the mock base's clock to now, so build_write_payload() judges active windows correctly. + + MockBase fixes its clock when it is built, and the discharge floor in the payload depends on + whether an export window is running at this minute. + """ + base = client.base + base.now_utc = datetime.now(base.local_tz) + base.now_utc_exact = base.now_utc + base.midnight_utc = base.now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + base.minutes_now = base.now_utc.hour * 60 + base.now_utc.minute + + +def _build_hanchu_cli(args, automatic=False): # pragma: no cover + """Construct a HanchuAPI around a MockBase for a standalone run. + + control_enable is always off: the harness writes through _write_payload() directly, after asking, + and must never have run() or the reconcile loop write on its behalf. Pacing is off because each + harness write is deliberate and the restore must not be held back. + """ + from mock_base import MockBase + + client = HanchuAPI( + MockBase(), + account=args.account, + password=args.password, + inverter_sn=args.serial, + automatic=automatic, + control_enable=False, + api_delay=args.api_delay, + min_write_interval=0, + ) + client.api_debug = not args.no_trace + # Recorded on args so _cli_main() can close its HTTP session however the driver returns. + args.client = client + return client + + +async def _cli_main(driver, args): # pragma: no cover + """Run one harness driver, then close the HTTP session it opened, whichever way it returned.""" + try: + await driver(args) + finally: + client = getattr(args, "client", None) + if client is not None: + await client.close_session() + + +def _cli_slot_table(settings): # pragma: no cover + """Format the device's charge and discharge slots as one readable line each.""" + lines = [] + for label, start_key, end_key in (("charge", HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END), ("discharge", HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END)): + slots = [] + for index in range(1, HANCHU_SLOT_COUNT + 1): + start, end = settings.get(start_key.format(index)), settings.get(end_key.format(index)) + if start is None and end is None: + slots.append("{}: not read".format(index)) + elif _cli_number(start) == HANCHU_SLOT_DISABLED and _cli_number(end) == HANCHU_SLOT_DISABLED: + slots.append("{}: off".format(index)) + else: + slots.append("{}: {}-{}".format(index, seconds_to_hhmmss(start)[:5], seconds_to_hhmmss(end)[:5])) + lines.append(" {:9} slots {}".format(label, " ".join(slots))) + return lines + + +def _cli_print_device(client, sn): # pragma: no cover + """Print everything a tester needs to check one device against the Hanchu app.""" + settings = client.device_settings.get(sn, {}) + print("\n=== {} ===".format(sn)) + mode = settings.get(HANCHU_KEY_WORK_MODE) + print(" work mode {} (raw {!r}){}".format(HANCHU_WORK_MODES.get(_cli_number(mode), "unknown"), mode, "" if _cli_number(mode) == HANCHU_WORK_MODE_FOR_SCHEDULE else " - Predbat switches this to User-defined when it writes a window")) + for line in _cli_slot_table(settings): + print(line) + for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER, HANCHU_KEY_CHARGE_SOC, HANCHU_KEY_DISCHARGE_SOC, HANCHU_KEY_GRID_CHARGE_SOC): + print(" {:20} {!r}".format(key, settings.get(key))) + + menu = client.device_ranges.get(sn) or {} + print(" ranges (compare with the Hanchu app):") + for key in HANCHU_RANGES: + low, high = client.ranges_for(sn)[key] + print(" {:20} {}-{} from {}".format(key, low, high, "the menu" if key in menu else "the BUILT-IN DEFAULT")) + if not menu: + print(" WARNING: the menu gave no ranges, so every limit above is a default. On a 3.68, 6 or 10 kW unit the power limits will be wrong.") + + print(" telemetry {}".format(json.dumps(client.device_values.get(sn, {})))) + print(" energy today {}".format(json.dumps(client.device_energy.get(sn, {})))) + print(" derived soc_max {:.2f} kWh, battery_rate_max {:.0f} W, inverter_limit {:.0f} W".format(client.battery_capacity(sn), client.battery_rate_max(sn), client.inverter_limit(sn))) + model = client.slot_model.get(sn) + print( + " time slots {}{}".format( + {"slots": "TCT_/TDT_ slots - the format Predbat writes", "array": "ARRAY periods with FLAG_ENABLE_CYCLE - Predbat will NOT control this device", "unknown": "the menu shows no slot items"}.get(model, "menu not read"), + ", shown in work mode(s) {}".format(client.slot_work_modes.get(sn)) if client.slot_work_modes.get(sn) else "", + ) + ) + detail = client.device_detail.get(sn, {}) + print(" device status {}".format(detail.get("status", "not reported"))) + + +async def _cli_print_coupling(client, sn): # pragma: no cover + """Print the raw fields that tell an AC-coupled system from a DC-coupled one, and the generator's. + + On an AC-coupled system the solar is only in bypMeterTotalPower and pvTtPwr reads zero, and + bypMeterSwitch is 1; the component adds the bypass meter into pv_power on those systems. The raw + fields are printed so a tester can check that, and report a system with both kinds of PV. The + generator fields are printed for the same reason: nobody yet knows their sign, or whether the + generator's output is already counted in the load or grid readings. + """ + ok, data = await client._post("device_status", body={"sn": sn}) + if not ok or not isinstance(data, dict): + print(" coupling could not read getDeviceStatus") + return + switch = data.get("bypMeterSwitch") + print(" coupling bypMeterSwitch={!r} bypMeterTotalPower={!r} pvTtPwr={!r} -> {}".format(switch, data.get("bypMeterTotalPower"), data.get("pvTtPwr"), {1: "AC-coupled", 0: "DC-coupled"}.get(_cli_number(switch), "unknown"))) + print(" generator hasDg={!r} dgPAcTotal={!r} {} (load {!r}, grid {!r}, battery {!r})".format(data.get("hasDg"), data.get("dgPAcTotal"), data.get("dgPAcTotalUnit") or "", data.get("loadPwr"), data.get("meterPPwr"), data.get("batP"))) + # Raw fields whose meaning is not yet known, printed so a run can be compared against the app: the + # several mode fields, the vendor's own schedulers (which could fight Predbat if switched on), and + # the two different "PV today" figures the status carries. + for label, keys in ( + ("modes", ("devModel", "workModeCmb", "workMode", "workModeFlag", "pcsStatusDesc", "batSts")), + ("vendor schedulers", ("strategyOpen", "schedulePending", "scheduleExecuting", "smartflexAuthSts", "smartflexSchedule", "smartflexExecuting")), + ("today (status)", ("pvTdGe", "pvDge", "loadTdEe", "gridTdEe", "gridTdFe", "batTdChg", "batTdDschg")), + ): + print(" {:15} {}".format(label, " ".join("{}={!r}".format(key, data.get(key)) for key in keys))) + + +async def _cli_print_menu(client, sn, dump_path=None): # pragma: no cover + """List the menu's energy items, and optionally save the whole raw menu to a file. + + The menu can list one setting several times, each for a different set of product models, and a + trace line is cut at 2000 characters, so the full menu is the only way to see which entry the + range parser should have used. + """ + ok, data = await client._post("menu", body={"sn": sn}) + if not ok or not isinstance(data, dict): + print(" menu could not be read") + return + if dump_path: + path = dump_path if len(client.device_list) == 1 else "{}_{}".format(sn, dump_path) + with open(path, "w") as handle: + json.dump(data, handle, indent=2, ensure_ascii=False) + print(" menu saved in full to {}".format(path)) + print(" menu energy items (signal, type, min-max, models, shown when):") + for item in client._menu_items(data): + signal = item.get("itemCodeSignal") or item.get("itemCode") or "?" + bounds = "{}-{}".format(item.get("minVal"), item.get("maxVal")) if item.get("minVal") not in (None, "") or item.get("maxVal") not in (None, "") else "" + listener = item.get("listener") if isinstance(item.get("listener"), dict) else {} + print( + " {:32} type {:3} {:12} models {}{}{}".format( + signal, + str(item.get("itemType")), + bounds, + item.get("productModel") or "all", + " when {}={}".format(listener.get("code"), listener.get("show")) if listener else "", + " hidden" if item.get("hidden") else "", + ) + ) + + +async def _cli_prepare(client): # pragma: no cover + """Log in, pick the device and read its settings, ranges and SoC before a test write. + + Returns (sn, settings, soc), or (None, None, None) when anything needed for a safe write is + missing. Without the current settings there is nothing to restore, so the harness refuses. + """ + if not await client.ensure_token(): + print("LOGIN FAILED{}".format(": {}".format(client.last_api_error) if client.last_api_error else "")) + return None, None, None + await client.refresh_static() + if not client.device_list: + print("NO DEVICES - discovery {}".format("failed" if client.discovery_ok is False else "found no DTU on this account")) + return None, None, None + sn = client.device_list[0] + if len(client.device_list) > 1: + print("Several devices found ({}); using {} - pass --serial to choose".format(", ".join(client.device_list), sn)) + await client.refresh_config() + await client.refresh_power() + settings = client.device_settings.get(sn) + if not settings: + print("Could not read {}'s current settings (iotGet) - refusing to write without a snapshot to restore".format(sn)) + return None, None, None + soc = client.device_values.get(sn, {}).get("soc") + if soc is None: + print("Could not read {}'s SoC (getDeviceStatus) - refusing to write".format(sn)) + return None, None, None + _cli_print_device(client, sn) + if client.slot_model.get(sn) == "array": + print("{} uses the array time-slot format, which Predbat does not write - refusing. Please report this menu on batpred#5305".format(sn)) + return None, None, None + if client.device_online.get(sn) is False: + print("{} is offline, so a write cannot reach it - refusing".format(sn)) + return None, None, None + return sn, settings, soc + + +async def _cli_await_settings(client, sn, expected, timeout=120, interval=10): # pragma: no cover + """Read the settings back until they match expected, or the timeout runs out. + + Cloud round trips take 7-45s (batpred#5305), so one immediate read could report a write that + has in fact landed as missing. Returns (actual, mismatches, seconds_waited). + """ + started = time.time() + while True: + ok, data = await client._post("iot_get", body={"sn": sn, "devType": HANCHU_DEV_TYPE_DTU, "keys": list(expected)}) + actual = dict(data) if ok and isinstance(data, dict) else {} + mismatches = settings_mismatches(expected, actual) + waited = int(time.time() - started) + if not mismatches or waited >= timeout: + return actual, mismatches, waited + print(" ...read-back does not match yet after {}s ({} key(s) differ), retrying in {}s".format(waited, len(mismatches), interval)) + await asyncio.sleep(interval) + + +def _cli_print_mismatches(mismatches): # pragma: no cover + """Print each setting that did not read back as written.""" + for key, wanted, got in mismatches: + print(" {:20} wrote {!r}, reads {!r}".format(key, wanted, got)) + + +async def _cli_write(client, sn, payload, label): # pragma: no cover + """Send one payload, then read it back. Returns True only when the device holds every value.""" + print("\nWriting {} ({} setting(s))...".format(label, len(payload))) + if not await client._write_payload(sn, payload, force=True): + print(" write NOT accepted: {}".format(client.last_api_error or "see the Warn: lines above")) + return False + _actual, mismatches, waited = await _cli_await_settings(client, sn, payload) + if mismatches: + print(" accepted, but {} setting(s) still differ after {}s:".format(len(mismatches), waited)) + _cli_print_mismatches(mismatches) + return False + print(" accepted and read back intact after {}s".format(waited)) + return True + + +async def _cli_restore(client, sn, snapshot, keys, snapshot_path, args): # pragma: no cover + """Put keys back to their snapshot values, and say exactly what to run if that fails.""" + payload, missing = restore_payload(snapshot, keys) + if missing: + print("WARNING: the snapshot holds no value for {}, so they stay as the test left them".format(", ".join(missing))) + if not payload: + print("Nothing to restore") + return + if not await _cli_write(client, sn, payload, "the original settings back"): + print("WARNING: {} was NOT fully restored. Check it in the Hanchu app, or retry with:".format(sn)) + print(" python3 hanchu.py --account --restore {}".format(snapshot_path)) + + +def _cli_snapshot(client, sn, settings, args): # pragma: no cover + """Save the pre-write settings to disk and tell the tester how to restore them by hand.""" + path = save_cli_snapshot(args.snapshot_dir, sn, settings) + print("\nSaved the current settings to {}".format(path)) + print("If this run is interrupted, put them back with: python3 hanchu.py --account --restore {}".format(path)) + return path + + +async def test_hanchu_api(args): # pragma: no cover + """Read-only report: log in, discover, run one full cycle with control off, and print the results.""" + client = _build_hanchu_cli(args, automatic=True) + print("Calling run() once (read-only: control is off, nothing is written to the inverter)...") + ok = await client.run(seconds=0, first=True) + if not ok: + if not client._token: + print("\nLOGIN FAILED - check the e-mail and password you use in the Hanchu app{}".format(": {}".format(client.last_api_error) if client.last_api_error else "")) + elif client.discovery_ok is False: + print("\nLOGGED IN, but the device list call failed{}".format(": {}".format(client.last_api_error) if client.last_api_error else "")) + elif not client.device_list: + print("\nLOGGED IN, but no DTU was found on this account{}".format(" matching --serial {}".format(args.serial) if args.serial else "")) + else: + print("\nDISCOVERY OK ({}), but the first telemetry poll came back empty - see the Warn: lines above".format(", ".join(client.device_list))) + await client.final() + return + for sn in client.device_list: + _cli_print_device(client, sn) + await _cli_print_coupling(client, sn) + await _cli_print_menu(client, sn, args.dump_menu) + print("\nThe 'Set arg' lines above are what hanchu_automatic: true would put in apps.yaml.") + await client.final() + print("Done") + + +async def test_hanchu_write_readback(args): # pragma: no cover + """Write a charge window three hours away, read it back, then restore the original settings. + + Proves iotSet lands and the slot encoding reads back as written, without moving any energy. + """ + client = _build_hanchu_cli(args) + sn, settings, soc = await _cli_prepare(client) + if not sn: + return + _cli_refresh_clock(client) + start = cli_future_start(client.minutes_now, args.minutes) + schedule, notes = build_cli_schedule("charge", start, soc, settings, client.ranges_for(sn), args.minutes, args.power, require_effect=False) + payload = client.build_write_payload(sn, schedule) + print("\nWrite test: a charge window {}-{}, away from now so the battery does not move".format(schedule["charge"]["start"][:5], schedule["charge"]["end"][:5])) + for note in notes: + print(" note: {}".format(note)) + print("Payload:\n{}".format(json.dumps(payload, indent=2))) + if not _cli_confirm("Write this to {} and then restore it? [y/N] ".format(sn), args.yes): + return + path = _cli_snapshot(client, sn, settings, args) + try: + if await _cli_write(client, sn, payload, "the test window"): + print("PASS: the write landed and read back intact") + else: + print("FAIL: the write did not land as sent") + except (KeyboardInterrupt, asyncio.CancelledError): + print("\nInterrupted - restoring") + finally: + await _cli_restore(client, sn, settings, list(payload), path, args) + print("Done") + + +async def test_hanchu_live_window(args): # pragma: no cover + """Run one Predbat state on the real inverter for a few minutes, watch the battery, then restore. + + The schedule goes through the component's own build_write_payload(), so this tests the payload + Predbat would actually send. A charge or export window ends on the inverter by itself at the + window end even if this process dies; a freeze has no end time, so the snapshot is the way back. + """ + client = _build_hanchu_cli(args) + sn, settings, soc = await _cli_prepare(client) + if not sn: + return + scenario = args.live_window + _cli_refresh_clock(client) + # Predbat's clock, and so every window it writes, sits on the 5-minute planning grid: at 19:29 a + # window starts at 19:25. The test starts on the same grid, so build_write_payload() sees the + # window as running exactly as it would in Predbat, and is lengthened by the minutes already + # past the grid line so it still runs for --minutes from now. + now = datetime.now() + real_minutes = now.hour * 60 + now.minute + try: + schedule, notes = build_cli_schedule(scenario, client.minutes_now, soc, settings, client.ranges_for(sn), args.minutes + real_minutes - client.minutes_now, args.power, target=args.target) + except ValueError as error: + print("\nNot running {}: {}".format(scenario, error)) + return + payload = client.build_write_payload(sn, schedule) + if scenario.startswith("freeze"): + if args.user_defined: + payload[HANCHU_KEY_WORK_MODE] = HANCHU_WORK_MODE_FOR_SCHEDULE + elif HANCHU_KEY_WORK_MODE not in payload and _cli_number(settings.get(HANCHU_KEY_WORK_MODE)) != HANCHU_WORK_MODE_FOR_SCHEDULE: + print("\nNOTE: the work mode is not User-defined, and a freeze writes no window, so Predbat would not change it.") + print(" In practice an earlier Predbat window has usually set User-defined already; add --user-defined to test that case.") + + window = schedule["charge"] if schedule["charge"]["enable"] else schedule["export"] + if window["enable"]: + print("\nLive test: {} window {}-{}. Expect {}.".format(scenario, window["start"][:5], window["end"][:5], HANCHU_CLI_EXPECTATION[scenario])) + print("The window uses this computer's clock - check it matches the inverter's.") + # Watch until the window ends on the inverter, not for a fixed time from the write. + hold_seconds = hhmmss_to_seconds(window["end"]) - (real_minutes * 60 + now.second) + else: + print("\nLive test: {} for {} minute(s), with no window - it lasts until the restore. Expect {}.".format(scenario, args.minutes, HANCHU_CLI_EXPECTATION[scenario])) + hold_seconds = args.minutes * 60 + for note in notes: + print(" note: {}".format(note)) + print("Payload:\n{}".format(json.dumps(payload, indent=2))) + if not _cli_confirm("Write this to {} now, run it, and then restore? [y/N] ".format(sn), args.yes): + return + if window["enable"]: + # The tester may have taken a while to answer the prompt; the window's end has not moved. + now = datetime.now() + hold_seconds = hhmmss_to_seconds(window["end"]) - (now.hour * 3600 + now.minute * 60 + now.second) + if hold_seconds <= 0: + print("The window has already ended while waiting for an answer - nothing written") + return + path = _cli_snapshot(client, sn, settings, args) + written_from = time.time() + + samples = [] + try: + if await _cli_write(client, sn, payload, "the {} test".format(scenario)): + # The write and read-back took real time, which the window has already used up. + hold_seconds = max(0, hold_seconds - int(time.time() - written_from)) + started = time.time() + print("\nWatching for {}s (Ctrl-C restores early). Battery: negative = charging. Grid: positive = exporting.".format(hold_seconds)) + while time.time() - started < hold_seconds: + await client.refresh_power() + values = client.device_values.get(sn, {}) + # Measured from the write, not from the read-back: the settle time is the inverter's. + elapsed = int(time.time() - written_from) + samples.append(dict(values, elapsed=elapsed)) + print( + " {} +{:4}s SoC {}% battery {} W grid {} W pv {} W ac-pv {} W load {} W{}".format( + datetime.now().strftime("%H:%M:%S"), + elapsed, + values.get("soc"), + values.get("battery_power"), + values.get("grid_power"), + values.get("pv_power"), + values.get("ac_pv_power"), + values.get("load_power"), + " generator {} W".format(values.get("generator_power")) if "generator_power" in values else "", + ) + ) + await asyncio.sleep(max(1, min(args.interval, hold_seconds - (time.time() - started)))) + except (KeyboardInterrupt, asyncio.CancelledError): + print("\nInterrupted - restoring") + finally: + verdict, reason = judge_live_window(scenario, samples, target=window.get("soc") if args.target is not None and window["enable"] else None) + print("\n{}: {} - {}".format(verdict, scenario, reason)) + await _cli_restore(client, sn, settings, list(payload), path, args) + print("Done") + + +async def restore_hanchu_snapshot(args): # pragma: no cover + """Write a saved snapshot back, to undo a test that did not get to restore itself.""" + try: + serial, snapshot = load_cli_snapshot(args.restore) + except ValueError as error: + print(error) + return + args.serial = args.serial or serial + if args.serial.lower() != serial.lower(): + print("The snapshot is for {}, not {} - refusing".format(serial, args.serial)) + return + client = _build_hanchu_cli(args) + if not await client.ensure_token(): + print("LOGIN FAILED{}".format(": {}".format(client.last_api_error) if client.last_api_error else "")) + return + await client.refresh_static() + if serial not in client.device_list: + print("{} is not on this account (found: {})".format(serial, ", ".join(client.device_list) or "nothing")) + return + payload, missing = restore_payload(snapshot, HANCHU_CLI_RESTORE_KEYS) + print("Restoring {} from {}:\n{}".format(serial, args.restore, json.dumps(payload, indent=2))) + if not _cli_confirm("Write these settings back to {}? [y/N] ".format(serial), args.yes): + return + await _cli_restore(client, serial, snapshot, HANCHU_CLI_RESTORE_KEYS, args.restore, args) + print("Done") + + +def main(): # pragma: no cover + """Command-line entry point: Hanchu diagnostics and live-hardware tests.""" + import argparse + import getpass + + parser = argparse.ArgumentParser( + description="Hanchu ESS cloud diagnostics and live-hardware tests. With no mode option it is read-only.", + epilog="Run from apps/predbat. The password can also come from HANCHU_PASSWORD or --config; otherwise you are prompted for it.", + ) + parser.add_argument("--account", help="The e-mail address you sign into the Hanchu app with") + parser.add_argument("--password", help="The Hanchu app password (prefer HANCHU_PASSWORD or the prompt, which keep it out of shell history)") + parser.add_argument("--serial", default=None, help="Drive this DTU serial when the account has several") + parser.add_argument("--config", help="Read hanchu_account, hanchu_password and hanchu_inverter_sn from an apps.yaml, resolving !secret as Predbat does") + parser.add_argument("--api-delay", type=float, default=1, help="Seconds to wait between API calls (default 1)") + parser.add_argument("--no-trace", action="store_true", help="Stop printing every API request and response") + parser.add_argument("--dump-menu", metavar="FILE", help="With the read-only report, save the device's full settings menu as JSON to FILE") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--write-test", action="store_true", help="Write a charge window three hours from now, read it back, then restore - moves no energy") + mode.add_argument("--live-window", choices=HANCHU_CLI_SCENARIOS, help="Run this Predbat state on the inverter now, watch the battery, then restore") + mode.add_argument("--restore", metavar="SNAPSHOT", help="Write back a snapshot file a test saved") + parser.add_argument("--minutes", type=int, default=10, help="Length of the test window in minutes (default 10)") + parser.add_argument("--power", type=int, default=1000, help="Charge or export power for the test in watts (default 1000)") + parser.add_argument("--target", type=int, default=None, help="Charge target or export floor in percent for --live-window charge/export (default: 10 points from the current SoC). Set it just past the current SoC to watch the inverter stop at it") + parser.add_argument("--interval", type=int, default=30, help="Seconds between readings during --live-window (default 30)") + parser.add_argument("--user-defined", action="store_true", help="With a freeze test, also set the User-defined work mode, as an earlier Predbat window would have") + parser.add_argument("--snapshot-dir", default=".", help="Where to save the pre-write snapshot (default: the current directory)") + parser.add_argument("--yes", action="store_true", help="Do not ask before writing") + args = parser.parse_args() + + if args.config: + import yaml + from utils import load_apps_yaml + + try: + config, _ = load_apps_yaml(args.config) + except (yaml.YAMLError, KeyError, OSError, ValueError) as error: + parser.error("could not read Hanchu credentials from {}: {}".format(args.config, error)) + merged, used = merge_hanchu_credentials({"account": args.account, "password": args.password, "serial": args.serial}, config or {}) + args.account, args.password, args.serial = merged["account"], merged["password"], merged["serial"] + print("Read from {}: {}".format(args.config, ", ".join(used) if used else "nothing - no hanchu_ keys found")) + if not args.account: + parser.error("no account: pass --account, or --config pointing at an apps.yaml holding hanchu_account") + if not args.password: + args.password = os.environ.get("HANCHU_PASSWORD") + if not args.password: + try: + args.password = getpass.getpass("Hanchu password for {}: ".format(args.account)) + except (EOFError, KeyboardInterrupt): + parser.error("no password: pass --password, set HANCHU_PASSWORD, or use --config") + if args.minutes < 2: + parser.error("--minutes must be at least 2") + + if args.write_test: + asyncio.run(_cli_main(test_hanchu_write_readback, args)) + elif args.live_window: + asyncio.run(_cli_main(test_hanchu_live_window, args)) + elif args.restore: + asyncio.run(_cli_main(restore_hanchu_snapshot, args)) + else: + asyncio.run(_cli_main(test_hanchu_api, args)) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/apps/predbat/hanchu_const.py b/apps/predbat/hanchu_const.py new file mode 100644 index 000000000..93b737b2f --- /dev/null +++ b/apps/predbat/hanchu_const.py @@ -0,0 +1,570 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Hanchu ESS cloud API constants +# ----------------------------------------------------------------------------- + +"""Constants and pure helpers for the Hanchu ESS cloud component. + +Kept separate from hanchu.py so the wire format, the parameter ranges and the +seconds-since-midnight time encoding can be tested without constructing a component. + +PROVENANCE - READ THIS BEFORE TRUSTING ANY NAME IN THIS FILE +============================================================ +Hanchu publish NO API specification. Every endpoint, parameter and field below was learned by +READING three MIT-licensed Home Assistant integrations. None of their code, structure, comments +or docstrings is reproduced here - only the wire facts, re-expressed in Predbat's own idiom. + +The sources, and the short names used in the annotations below: + + UPTON https://github.com/upton68/hanchu-ess-ha - the actively maintained fork (~281 + commits). Treated as authoritative wherever the three disagree. + GUOXIA https://github.com/guoxiatech/hanchu-ess-ha - the upstream UPTON forked from + (~128 commits). Its api.py is a near-identical earlier copy, which makes it a weak + independent witness for the shared request layer but a genuine one for the + endpoints it also carries. + BLUSTERY https://github.com/Blustery7752/hanchu-ess - a thinner third integration. It talks + to a DIFFERENT API surface (the web app's, not the Home Assistant one), so where it + agrees on a PARAMETER NAME that is strong independent corroboration - the same + control key reached over a different path. + +GUOXIA is Hanchu's OWN integration (Guoxiatech is the company trading as Hanchu), so the shared +login and request layer in GUOXIA and UPTON is the vendor's code, not a third-party guess. It was +read-only; UPTON added the control side. (Confirmed by UPTON's maintainer on +springfall2008/batpred#5305.) + +Every entry carries one of four markers: + + HARDWARE confirmed on a real Hanchu iESS system running Predbat, by UPTON's maintainer on + springfall2008/batpred#5305. One system only, so other models may still differ. + OBSERVED read in exactly one repo, at the file named. + CORROBORATED read in two or more repos, files named. + INFERRED NOT read anywhere. Derived by reasoning, or assumed from how Predbat needs to use + it. Treat as a guess until hardware says otherwise. + +Anything marked INFERRED is the first thing a real connection will disprove. +""" + +# --- Base URL --------------------------------------------------------------------------------- +# CORROBORATED: UPTON const.py BASE_URL, GUOXIA const.py BASE_URL (both "https://iess3.hanchuess.com", +# both overridable by a HANCHUESS_URL environment variable, which is a Home Assistant testing +# convenience and is deliberately not reproduced). BLUSTERY const.py DEFAULT_BASE_URL is the same +# host with a trailing "/gateway/", i.e. the same origin reached with the path prefix folded in. +HANCHU_BASE_URL = "https://iess3.hanchuess.com" + +# --- Endpoints -------------------------------------------------------------------------------- +# All are POST. The "/gateway/app/ha/..." family is a surface the vendor appears to have added for +# the Home Assistant integration: it takes and returns PLAIN JSON. +# +# BLUSTERY reaches equivalent operations through "/gateway/platform/..." instead +# (platform/deviceNew/iotGet, platform/deviceNew/iotSet, platform/remoteContrDtu/fastChargeDischarge, +# platform/homePage/stationInfo) with AES-CBC-encrypted bodies. We use the app/ha family because it +# is the maintained one AND because it needs no crypto - see HANCHU_ENCRYPTED_ENDPOINTS_NOT_USED. +HANCHU_ENDPOINTS = { + # CORROBORATED: UPTON api.py async_login, GUOXIA api.py async_login. Body {"account", "pwd"}, + # password in PLAINTEXT over TLS. Response data is the bare token STRING, not an object. + # DISAGREEMENT: BLUSTERY api.py async_login posts to "identify/auth/login/account" with the + # password RSA-encrypted (PKCS1 v1.5) and the whole body AES-encrypted. See the module docstring + # of hanchu.py - we follow UPTON. + "login": "/gateway/identify/auth/token", + # CORROBORATED: UPTON api.py async_refresh_token, GUOXIA api.py async_refresh_token. + # Body {"token": }, response data is the new token string. + "refresh": "/gateway/identify/auth/token/refresh", + # CORROBORATED: UPTON api.py async_get_devices, GUOXIA api.py async_get_devices. Empty body {}. + "device_list": "/gateway/app/ha/getDeviceList", + # CORROBORATED: UPTON api.py async_get_device_status, GUOXIA api.py async_get_device_status. + "device_status": "/gateway/app/ha/getDeviceStatus", + # CORROBORATED: UPTON api.py async_get_device_statistics, GUOXIA api.py async_get_device_statistics. + "device_statistics": "/gateway/app/ha/getDeviceStatistics", + # CORROBORATED: UPTON api.py async_get_menu, GUOXIA api.py async_get_menu. Returns the phone + # app's settings menu, which is where the per-device min/max for each control key lives. + "menu": "/gateway/app/ha/menu", + # CORROBORATED: UPTON api.py async_iot_get, GUOXIA api.py async_iot_get; the same operation on + # BLUSTERY's surface is platform/deviceNew/iotGet (api.py get_device_settings), and it sends the + # SAME three fields {devType, sn, keys}, which is why the request shape is corroborated rather + # than merely observed. + "iot_get": "/gateway/app/ha/iotGet", + # CORROBORATED: UPTON api.py async_device_control, GUOXIA api.py async_device_control; + # BLUSTERY api.py set_device_setting uses platform/deviceNew/iotSet with the same + # {devType, sn, value} shape. + "iot_set": "/gateway/app/ha/iotSet", + # CORROBORATED: UPTON api.py async_fast_charge_discharge, GUOXIA api.py async_fast_charge_discharge, + # BLUSTERY api.py fast_charge_discharge (as platform/remoteContrDtu/fastChargeDischarge). All three + # send {sn, act, duration}. + "fast_charge": "/gateway/app/ha/fastChargeDischarge", +} + +# Endpoints this component deliberately does NOT call, recorded so a later reader does not have to +# rediscover them: +# /gateway/platform/station/detail (OBSERVED: UPTON api.py async_get_station_detail) +# /gateway/platform/bmsInfo/queryBatteryDataDivisions (OBSERVED: UPTON api.py async_get_battery_data) +# Both take an AES-CBC-encrypted, Base64 body keyed with a FIXED key and IV hard-coded in the +# integration (OBSERVED: UPTON const.py AES_IV / AES_SECRET_KEY, crypto.py). They carry per-pack BMS +# detail - cell temperatures, pack voltage/current, SoH - that Predbat does not plan with. Skipping +# them keeps a hard-coded vendor key out of Predbat and removes a crypto dependency, at the cost of +# per-pack temperature and SoH sensors. Revisit only if Predbat gains a use for them. +HANCHU_ENCRYPTED_ENDPOINTS_NOT_USED = ("/gateway/platform/station/detail", "/gateway/platform/bmsInfo/queryBatteryDataDivisions") + +# --- Request/response envelope ---------------------------------------------------------------- +# CORROBORATED: UPTON api.py _headers, GUOXIA api.py _headers. "appPlat: ha" identifies the client +# to the server; access-token carries the session token; locale is normalised to "en" or "zh" +# (anything starting "zh" becomes "zh", everything else "en"). +HANCHU_HEADER_PLATFORM = "ha" +HANCHU_HEADER_TOKEN = "access-token" +HANCHU_HEADER_LOCALE = "en" + +# CORROBORATED: UPTON api.py _request, GUOXIA api.py _request. The envelope is +# {"code": int, "success": bool, "msg": str, "data": ...}. Success is `success: true`; HTTP 200 with +# code 401 means the token has expired; code 100 is a body-level failure whose msg names the reason. +HANCHU_CODE_OK = 200 +HANCHU_CODE_UNAUTHORISED = 401 +HANCHU_CODE_FAILED = 100 + +# HARDWARE (batpred#5305): the menu answers code 200 with NO success flag, so it is judged on the +# code alone - which is also how the official integration reads it (GUOXIA api.py async_get_menu +# accepts code == 200). Kept to the endpoints actually observed doing this: everywhere else a bare +# code 200 without success: true is still treated as a failure. +HANCHU_SUCCESS_BY_CODE_ENDPOINTS = ("menu",) + +# OBSERVED: UPTON api.py ReauthRequired is raised when the refresh endpoint answers code 100, and +# the component then asks Home Assistant to re-authenticate. Note the INTERNAL INCONSISTENCY in the +# source: that exception's own docstring says the trigger is code 90076, while the code that raises +# it tests for 100. We follow the CODE, not the docstring, and fall back to a full re-login rather +# than giving up - Predbat holds the account credentials, so it can always log in again. +HANCHU_CODE_REFRESH_REJECTED = 100 + +# OBSERVED: UPTON api.py TOKEN_REFRESH_DAYS = 25; GUOXIA api.py carries the identical constant, but +# as a near-copy of the same file it is weak corroboration. The real server-side lifetime is unknown - +# 25 days is the reference integration's own safety margin, not a documented figure. We refresh +# proactively at this age AND re-login reactively on a 401, so being wrong costs one extra request. +HANCHU_TOKEN_REFRESH_DAYS = 25 +HANCHU_TOKEN_REFRESH_SECONDS = HANCHU_TOKEN_REFRESH_DAYS * 24 * 3600 +# OBSERVED: UPTON api.py async_refresh_token - a forced refresh within 30 seconds of the previous +# attempt is suppressed, so a burst of 401s cannot become a refresh storm. +HANCHU_REFRESH_MIN_INTERVAL = 30 + +# OBSERVED: UPTON api.py _request uses a 15-second timeout and 3 attempts with a 2s, 4s backoff. +# GUOXIA's copy uses 10 seconds and a single attempt. UPTON's is the maintained figure. +HANCHU_TIMEOUT = 15 +HANCHU_RETRIES = 3 + +# --- Device type ------------------------------------------------------------------------------ +# CORROBORATED: UPTON config_flow.py filters the device list on devType == "2" and defaults to "2"; +# BLUSTERY api.py names the same value DEVICE_TYPE_DTU = "2". A STRING, not an int, in every repo. +# The DTU is the datalogger dongle, and it is what both iotGet and iotSet are addressed to. +HANCHU_DEV_TYPE_DTU = "2" + +# --- Device list fields ----------------------------------------------------------------------- +# OBSERVED: UPTON config_flow.py reads d["sn"] and d.get("devType") from each getDeviceList entry. +# Nothing else about an entry is read by any of the three repos, so the list's own model name, +# station id or capacity fields (if it has any) are UNKNOWN. This is why the component takes its +# ratings from getDeviceStatus instead. +HANCHU_DEVICE_SN_FIELD = "sn" +HANCHU_DEVICE_TYPE_FIELD = "devType" + +# --- Telemetry: getDeviceStatus --------------------------------------------------------------- +# Predbat sensor leaf -> getDeviceStatus field. +# All OBSERVED in UPTON sensor.py SENSORS. GUOXIA sensor.py carries batSoc, batP, pvTtPwr, meterPPwr +# and loadPwr under the same names, so those five are CORROBORATED; the rest are single-source. +HANCHU_TELEMETRY = { + # CORROBORATED (UPTON sensor.py "battery_soc", GUOXIA sensor.py). Carries a FRACTION, not a + # percent: UPTON applies "scale": 100 to it. See HANCHU_SOC_SCALE. + "soc": "batSoc", + # CORROBORATED (UPTON sensor.py "battery_power", GUOXIA sensor.py). Signed, POSITIVE ON CHARGE - + # UPTON derives battery_charge_power from it with derive_mode "positive" and + # battery_discharge_power with "negative_as_positive". Predbat wants the opposite sign, so this + # is negated on publish (HANCHU_TELEMETRY_NEGATE). + "battery_power": "batP", + # CORROBORATED (UPTON sensor.py "grid_power", GUOXIA sensor.py). Signed, POSITIVE ON IMPORT - + # UPTON derives grid_import_power with derive_mode "positive". Negated on publish. + "grid_power": "meterPPwr", + # CORROBORATED (UPTON sensor.py "pv_power", GUOXIA sensor.py). DC string total. + "pv_power": "pvTtPwr", + # CORROBORATED (UPTON sensor.py "load_power", GUOXIA sensor.py). House load. + "load_power": "loadPwr", + # OBSERVED (UPTON sensor.py "ac_coupled_pv_power"). AC-coupled PV measured through the bypass + # meter. HARDWARE (batpred#5305): on an AC-coupled system pvTtPwr reads 0 and the solar is only + # here, so it is added into pv_power when HANCHU_AC_COUPLED_FIELD says the system is AC-coupled. + # Also published on its own for visibility. + "ac_pv_power": "bypMeterTotalPower", + # OBSERVED (UPTON sensor.py "dg_power", shown only when hasDg is true). A diesel/backup generator's + # AC output. Read and published for diagnosis only - its sign and whether it is already inside + # loadPwr or meterPPwr are unknown, so it feeds nothing Predbat plans with until a live test says. + "generator_power": "dgPAcTotal", +} + +# HARDWARE (batpred#5305): 1 on an AC-coupled system, 0 on a DC-coupled one. On AC-coupled the live +# solar is only in bypMeterTotalPower (pvTtPwr reads 0) while getDeviceStatistics' daily pv counts +# it, so without adding it pv_power reads zero while pv_today climbs. On DC-coupled bypMeterTotalPower +# is 0. NOT yet seen: a system with both DC strings and AC-coupled PV, where pvTtPwr might already +# include the AC part. +HANCHU_AC_COUPLED_FIELD = "bypMeterSwitch" + +# OBSERVED (UPTON sensor.py condition_key "hasDg"): whether a generator is fitted. True in the +# status payload, 1 in the statistics one - read as a truthy value either way. +HANCHU_GENERATOR_FIELD = "hasDg" + +# OBSERVED (UPTON sensor.py DeviceStatusSensor STATUS_MAP): the datalogger's connection state. +# 0 offline, 1 online, 99 pending. While offline the cloud keeps serving the last readings, so the +# telemetry is stale and a write cannot reach the inverter. +HANCHU_DEVICE_STATUS_FIELD = "devStatus" +HANCHU_DEVICE_STATUS_OFFLINE = 0 +HANCHU_DEVICE_STATUSES = {0: "offline", 1: "online", 99: "pending"} + +# Fields whose sign is opposite to Predbat's convention (battery NEGATIVE on charge, grid NEGATIVE +# on import - see inverter.py's battery_power_invert handling and sunsynk_const.py's own note). +# Listed explicitly rather than inferred so the intent survives a refactor. +HANCHU_TELEMETRY_NEGATE = ("battery_power", "grid_power") + +# OBSERVED: UPTON sensor.py "battery_soc" carries "scale": 100 and its native_value multiplies by +# it, so batSoc is a 0..1 fraction on the wire. Applied on publish so Predbat sees a percent. +HANCHU_SOC_SCALE = 100 + +# OBSERVED: UPTON sensor.py _scale_auto_watt. Several power fields carry a SIBLING unit string +# naming their own unit ("W" or "kW"), because the API reports some of them in kW. Leaf -> unit field. +# A field with no entry here, or whose unit string is missing or unrecognised, falls back to the +# magnitude heuristic below. +HANCHU_TELEMETRY_UNIT_FIELD = { + "battery_power": "batPUnit", + "grid_power": "meterPPwrUnit", + "pv_power": "pvTtPwrUnit", + "ac_pv_power": "bypMeterTotalPowerUnit", + "generator_power": "dgPAcTotalUnit", +} + +# OBSERVED: UPTON sensor.py _scale_auto_watt - with no usable unit string, a magnitude below 10 is +# assumed to be kW. INFERRED consequence, stated because it matters: this misreads a genuine +# sub-10 W reading as kW. It only applies to fields with no unit sibling (loadPwr is the one in +# HANCHU_TELEMETRY), and a house load under 10 W is implausible, so the heuristic is kept - but it +# is a heuristic, and a real connection may show loadPwr carries a unit field after all. +HANCHU_WATT_HEURISTIC_THRESHOLD = 10 + +# OBSERVED (UPTON sensor.py "battery_capacity"); HARDWARE: in kWh, usable as soc_max without +# conversion. Usable battery capacity as the BMS designs it. The only capacity figure on the +# plain-JSON surface. +HANCHU_CAPACITY_FIELD = "bmsDesignCap" + +# OBSERVED: UPTON __init__.py _resolve_station_id reads getDeviceStatus["stationId"]. Not needed by +# this component (we call no station endpoint) but published as a diagnostic, and reported to the +# discovery catalogue as an account id. +HANCHU_STATION_FIELD = "stationId" + +# --- Daily energy: getDeviceStatistics -------------------------------------------------------- +# Predbat sensor leaf -> getDeviceStatistics field. All OBSERVED in UPTON sensor.py +# STATISTICS_SENSORS; GUOXIA sensor.py carries the same six names, so all six are CORROBORATED. +# kWh, TOTAL_INCREASING, resetting at midnight - Predbat's minute_data absorbs the reset. +HANCHU_ENERGY = { + "load_today": "load", + "import_today": "gridImport", + "export_today": "gridExport", + "pv_today": "pv", + "battery_charge_today": "batCharge", + "battery_discharge_today": "batDisCharge", + # OBSERVED (UPTON sensor.py "daily_dg_energy"). Diagnostic only, like generator_power. + "generator_today": "dgEp", +} + +# --- Control keys (iotGet / iotSet) ----------------------------------------------------------- +# These are the strongest-evidenced names in this file: all three repos name them identically, and +# BLUSTERY reaches them over a DIFFERENT endpoint, so agreement is not just a copied file. +# +# CORROBORATED: UPTON select.py WORK_MODES + number.py NUMBERS + time.py TIME_SLOTS, +# GUOXIA number.py/switch.py, BLUSTERY api.py DEFAULT_DEVICE_SETTING_KEYS (which lists +# WORK_MODE_CMB, CHG_PWR_LMT, DSCHG_PWR_LMT, DTU_AC_CHG_SOC_LMT, CHG_BAT_SOC_LMT, +# DSCHG_BAT_SOC_LMT and all twelve TCT_/TDT_ slot keys). +HANCHU_KEY_WORK_MODE = "WORK_MODE_CMB" +HANCHU_KEY_CHARGE_POWER = "CHG_PWR_LMT" +HANCHU_KEY_DISCHARGE_POWER = "DSCHG_PWR_LMT" +# OBSERVED (UPTON number.py): CHG_BAT_SOC_LMT is "Maximum Charge SOC" and DTU_AC_CHG_SOC_LMT is +# "Grid to Battery Charge Maximum". INFERRED from those names: the first is the battery's overall +# ceiling, solar included, and the second limits grid charging alone - so the grid one carries +# Predbat's charge target and the overall one is never written. Writing a 70% charge target into the +# overall ceiling would stop solar charging at 70% for the rest of the day. A live charge test with +# --target is the way to confirm the grid limit stops a charge window. +HANCHU_KEY_CHARGE_SOC = "CHG_BAT_SOC_LMT" +HANCHU_KEY_DISCHARGE_SOC = "DSCHG_BAT_SOC_LMT" +HANCHU_KEY_GRID_CHARGE_SOC = "DTU_AC_CHG_SOC_LMT" + +# CORROBORATED: UPTON time.py TIME_SLOTS names all twelve; BLUSTERY api.py lists the same twelve. +# THREE charge slots and THREE discharge slots, numbered from 1. +HANCHU_SLOT_COUNT = 3 +HANCHU_KEY_CHARGE_START = "TCT_START_{}" +HANCHU_KEY_CHARGE_END = "TCT_END_{}" +HANCHU_KEY_DISCHARGE_START = "TDT_START_{}" +HANCHU_KEY_DISCHARGE_END = "TDT_END_{}" + +# Every key the component reads back at startup and on each config tier, in one iotGet. +# OBSERVED: UPTON __init__.py async_setup_entry fetches exactly this set in a single call. +HANCHU_CONTROL_KEYS = ( + [HANCHU_KEY_WORK_MODE, HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER, HANCHU_KEY_CHARGE_SOC, HANCHU_KEY_DISCHARGE_SOC, HANCHU_KEY_GRID_CHARGE_SOC] + + [HANCHU_KEY_CHARGE_START.format(index) for index in range(1, HANCHU_SLOT_COUNT + 1)] + + [HANCHU_KEY_CHARGE_END.format(index) for index in range(1, HANCHU_SLOT_COUNT + 1)] + + [HANCHU_KEY_DISCHARGE_START.format(index) for index in range(1, HANCHU_SLOT_COUNT + 1)] + + [HANCHU_KEY_DISCHARGE_END.format(index) for index in range(1, HANCHU_SLOT_COUNT + 1)] +) + +# --- Work modes ------------------------------------------------------------------------------- +# OBSERVED: UPTON select.py WORK_MODES maps the four option names to STRING values "1".."4". +# GUOXIA ships no select.py at all (its select platform file is empty), so this is single-source. +# The values are strings on the wire; UPTON's websocket iotSet handler coerces numeric strings to +# int before sending, so the server evidently accepts either. We send int, matching that handler. +HANCHU_WORK_MODE_SELF_CONSUMPTION = 1 +HANCHU_WORK_MODE_BACKUP = 2 +HANCHU_WORK_MODE_USER_DEFINED = 3 +HANCHU_WORK_MODE_OFF_GRID = 4 +HANCHU_WORK_MODES = { + HANCHU_WORK_MODE_SELF_CONSUMPTION: "Self-consumption", + HANCHU_WORK_MODE_BACKUP: "Backup Energy", + HANCHU_WORK_MODE_USER_DEFINED: "User-defined", + HANCHU_WORK_MODE_OFF_GRID: "Off-grid", +} + +# HARDWARE: work mode 3, "User-defined", is the mode that honours the TCT_/TDT_ timed slots. No +# repo links the mode to the slots, so this was originally inferred from the name; it has since been +# confirmed on a real system. The component drives the mode to User-defined whenever it writes a +# window, and leaves it alone otherwise. hanchu_work_mode_control: False disables this and leaves +# the mode entirely to the user. +HANCHU_WORK_MODE_FOR_SCHEDULE = HANCHU_WORK_MODE_USER_DEFINED + +# --- Parameter ranges ------------------------------------------------------------------------- +# OBSERVED: UPTON __init__.py async_setup_entry seeds exactly these five fallback ranges, then +# OVERRIDES them per device from the menu endpoint (item minVal/maxVal keyed by itemCodeSignal). +# They are therefore defaults, not hard limits - the component asks the menu too, and prefers what +# it says. Key -> (min, max). +HANCHU_RANGES = { + HANCHU_KEY_CHARGE_POWER: (0, 5000), + HANCHU_KEY_DISCHARGE_POWER: (0, 5000), + # The overall charge ceiling. Read, never written - see HANCHU_KEY_CHARGE_SOC. + HANCHU_KEY_CHARGE_SOC: (50, 100), + # 45 is a real ceiling, and the mirror-image limitation: an export/discharge target above 45% + # cannot be expressed. + HANCHU_KEY_DISCHARGE_SOC: (5, 45), + # The grid-charge limit, which carries Predbat's charge target: a target below 20% cannot be + # expressed. See hanchu.py's clamp logging. + HANCHU_KEY_GRID_CHARGE_SOC: (20, 100), +} + +# OBSERVED: UPTON number.py gives both power controls "step": 100. Used for the published number +# entities so Predbat's own UI steps match what the device accepts. +HANCHU_POWER_STEP = 100 + +# --- Menu parsing ----------------------------------------------------------------------------- +# OBSERVED: UPTON __init__.py walks menu_data["data"]["energy"]["items"] - a list of GROUPS, each a +# list of items - and matches item["itemCodeSignal"] against the control key, reading "minVal" and +# "maxVal". Field names recorded here so the walk in hanchu.py does not bury them. +HANCHU_MENU_DATA = "data" +HANCHU_MENU_SECTION = "energy" +HANCHU_MENU_ITEMS = "items" +HANCHU_MENU_SIGNAL = "itemCodeSignal" +HANCHU_MENU_MIN = "minVal" +HANCHU_MENU_MAX = "maxVal" +# OBSERVED (UPTON sensor.py _parse_energy_menu, www/hanchuess-energy-card.js): an item's signal falls +# back to its itemCode, a time item carries its start and end signals comma-separated, and an item's +# "listener" {"code": "WORK_MODE_CMB", "show": "3,..."} names the work modes it is shown in. +HANCHU_MENU_CODE = "itemCode" +HANCHU_MENU_TYPE = "itemType" +HANCHU_MENU_LISTENER = "listener" +HANCHU_MENU_LISTENER_SHOW = "show" + +# OBSERVED (UPTON www/hanchuess-energy-card.js _submit): a SECOND time-slot format exists. Period +# items of type 82/83 hold an array value [charge mode, power limit, "HHMM" start, "HHMM" end], and a +# FLAG_ENABLE_CYCLE list switches each period on. A device whose menu offers those and no TCT_/TDT_ +# keys would accept TCT_ writes and ignore them, so the component does not drive it. +HANCHU_MENU_ARRAY_SLOT_TYPES = ("82", "83") +HANCHU_MENU_ENABLE_CYCLE = "FLAG_ENABLE_CYCLE" + +# --- iotSet acknowledgement ------------------------------------------------------------------- +# OBSERVED: BLUSTERY api.py set_device_setting reads data["responseSemMap"], a per-key map in which +# "1" means the device accepted that key and anything else means it rejected it. UPTON and GUOXIA +# both ignore this and treat envelope success alone as success. Single-source, but a per-key reject +# signal is worth having, so it is read WHEN PRESENT and its absence is never treated as a failure. +HANCHU_ACK_MAP = "responseSemMap" +HANCHU_ACK_OK = "1" + +# --- Fast charge / discharge ------------------------------------------------------------------ +# CORROBORATED: UPTON switch.py (2 / -2 / 3 / -3 with a duration in SECONDS, and 0 duration to +# stop), BLUSTERY api.py fast_charge_discharge docstring (identical act values and the same +# "duration required to start, ignored to stop"), GUOXIA switch.py. Also bounded to -3..3 by +# UPTON's own service schema. +# +# NOT USED by this component. Predbat drives the battery through the timed slots, which are +# declarative and idempotent; a duration-based imperative command would have to be re-issued before +# it lapsed and would fight the schedule. Recorded because it is the only way to get an immediate +# action out of this API, and a future "charge now" feature would want it. +HANCHU_FAST_CHARGE_START = 2 +HANCHU_FAST_CHARGE_STOP = -2 +HANCHU_FAST_DISCHARGE_START = 3 +HANCHU_FAST_DISCHARGE_STOP = -3 + +# --- Time encoding ---------------------------------------------------------------------------- +# OBSERVED: UPTON time.py _decode_time_value divides the raw value by 3600 for hours and takes +# (raw % 3600) // 60 for minutes, and async_set_value encodes hour * 3600 + minute * 60. So the +# slot keys carry SECONDS SINCE MIDNIGHT as an integer. Seconds are always zero in practice because +# the phone app only offers minutes. +HANCHU_SECONDS_PER_DAY = 24 * 3600 + +# HARDWARE: a slot is disabled by setting both its start and end to 00:00. There is no separate +# per-slot enable key anywhere in the twelve. +# +# User-defined mode runs ALL THREE charge and ALL THREE discharge slots. So once Predbat drives an +# inverter it owns every slot, and writes 00:00-00:00 into each one its plan does not use. A slot +# left over from the Hanchu app would otherwise keep charging or discharging the battery on its +# own timetable, against the plan. Nothing is written until Predbat presses the schedule write +# button for that inverter, so an inverter Predbat is not driving is never touched. +HANCHU_SLOT_DISABLED = 0 + +# --- Caching and pacing ----------------------------------------------------------------------- +HANCHU_STORAGE_MODULE = "hanchu" +HANCHU_CACHE_STATIC = "static" +HANCHU_CACHE_CONFIG = "config" +HANCHU_CACHE_CONTROL = "control" + +# Tier TTLs in minutes. No published rate limit exists. UPTON's default poll intervals are 60s for +# realtime and 300s for statistics (OBSERVED: UPTON const.py DEFAULT_REALTIME_INTERVAL, +# DEFAULT_STATISTICS_INTERVAL), which these mirror. The static and config tiers are Predbat's own +# choice. +HANCHU_TTL_STATIC = 8 * 60 +HANCHU_TTL_CONFIG = 30 +HANCHU_TTL_POWER = 1 +HANCHU_TTL_ENERGY = 5 + +# INFERRED, and the reason the write path is batched at all. UPTON stages every control change in +# memory and flushes the whole lot in ONE iotSet behind an explicit "Write Settings" button, +# and its own docstring says this exists to avoid rate limiting (OBSERVED: UPTON staging.py module +# docstring, __init__.py async_flush_staged). No numeric limit is documented anywhere. Predbat +# re-plans every few minutes and would otherwise send a write per setting per cycle, so a default +# pacing interval is applied on top of the batching. Sixty seconds is a deliberately mild default - +# unlike AlphaESS there is no evidence of a 24-hour budget - and it is user-tunable. +HANCHU_MIN_WRITE_INTERVAL = 60 + +# OBSERVED: UPTON __init__.py async_flush_staged retries a failed flush ONCE after 5 seconds before +# giving up and leaving the staged changes intact for the user to retry. +HANCHU_WRITE_RETRY_DELAY = 5 + +# A write that FAILED is retried on the next cycle rather than held for the full pacing interval: +# a rejected shorten leaves the inverter running past the plan, so waiting costs real energy. The +# reconcile loop runs about once a minute, so this still means at most one attempt per cycle. +HANCHU_FAILED_WRITE_INTERVAL = 30 + +# HARDWARE (batpred#5305): after this many consecutive rejected writes the user is notified, +# because the inverter may no longer match the plan and may need stepping in on by hand. +HANCHU_WRITE_FAILURE_NOTIFY = 3 + +# Keys never written to the log. The login body's password and account e-mail (testers post these +# logs on GitHub), and the token in every header and in both auth responses. `msg`, `code` and `data` for non-auth endpoints are always logged: nobody on +# the project has Hanchu hardware, so a tester's log is the only evidence available. +HANCHU_DEBUG_REDACT_KEYS = ("account", "pwd", "password", "token", "access-token", "accessToken") + + +def seconds_to_hhmmss(value): + """Convert a slot's seconds-since-midnight value to Predbat's HH:MM:SS control-entity form. + + Returns "00:00:00" for anything unusable, which is this API's only expression of a disabled + slot, so a missing or malformed value reads as "no window" rather than raising inside the poll + loop. Values at or beyond a full day wrap, because the wire format is a time of day. + """ + try: + total = int(float(value)) + except (TypeError, ValueError): + return "00:00:00" + total = total % HANCHU_SECONDS_PER_DAY if total >= 0 else 0 + return "{:02d}:{:02d}:{:02d}".format(total // 3600, (total % 3600) // 60, total % 60) + + +def hhmmss_to_seconds(value): + """Convert Predbat's HH:MM:SS control-entity value to seconds since midnight. + + Returns 0 for anything unusable. A 24:00:00 end - Predbat's way of saying midnight - is clamped + to one second before midnight rather than wrapping to 0, because 0 is how a disabled slot is + written and a full-day window must not collapse into one. + """ + text = str(value or "").strip() + if not text: + return 0 + parts = text.split(":") + try: + hours = int(parts[0]) + minutes = int(parts[1]) if len(parts) > 1 else 0 + seconds = int(float(parts[2])) if len(parts) > 2 else 0 + except (TypeError, ValueError): + return 0 + total = hours * 3600 + minutes * 60 + seconds + if total >= HANCHU_SECONDS_PER_DAY: + return HANCHU_SECONDS_PER_DAY - 60 + return max(0, total) + + +def split_window_seconds(start, end): + """Split an HH:MM:SS window at midnight into up to two seconds-since-midnight slot pairs. + + INVERTER_DEF sets can_span_midnight False, so Predbat itself avoids asking for a wrap-around, + but it still expresses "until midnight" as an end hour of 24 or more. The second pair carries + whatever runs past midnight and is (0, 0) - disabled - when nothing does. + + Returns ((start1, end1), (start2, end2)). + """ + start_seconds = hhmmss_to_seconds(start) + text = str(end or "").strip() + parts = text.split(":") + try: + end_seconds = int(parts[0]) * 3600 + (int(parts[1]) * 60 if len(parts) > 1 else 0) + except (TypeError, ValueError): + end_seconds = start_seconds + if end_seconds <= HANCHU_SECONDS_PER_DAY: + return (start_seconds, end_seconds), (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED) + return (start_seconds, HANCHU_SECONDS_PER_DAY - 60), (HANCHU_SLOT_DISABLED, end_seconds - HANCHU_SECONDS_PER_DAY) + + +def window_is_empty(start_seconds, end_seconds): + """Return True when a slot pair carries no time the inverter would act on. + + Covers both the disabled form (start == end) and a window an earlier step has inverted. An + inverted window is never written as a wrap-around: wrap behaviour is undocumented, so it is + written as disabled instead. + """ + return end_seconds <= start_seconds + + +def clamp_range(key, value, ranges=None): + """Clamp a control value into the range recorded for its key, returning (value, clamped). + + `clamped` is True when the value had to move, so the caller can log it once rather than + silently sending something other than what Predbat asked for. A key with no recorded range is + returned untouched. `ranges` lets a caller pass the per-device limits read from the menu + endpoint in place of the built-in defaults. + """ + limits = (ranges or HANCHU_RANGES).get(key) + try: + number = int(float(value)) + except (TypeError, ValueError): + number = 0 + if not limits: + return number, False + low, high = int(limits[0]), int(limits[1]) + bounded = max(low, min(high, number)) + return bounded, bounded != number + + +def scale_to_watts(value, unit=None, threshold=HANCHU_WATT_HEURISTIC_THRESHOLD): + """Normalise one power reading to watts, returning None when it cannot be read as a number. + + The sibling unit string is trusted when it is present and recognised ("kW" or "W"). With no + usable unit, a magnitude below `threshold` is assumed to be kW - the reference integration's + own fallback for the fields the API does not tag. See HANCHU_WATT_HEURISTIC_THRESHOLD for why + that heuristic is kept despite being a heuristic. + """ + try: + number = float(value) + except (TypeError, ValueError): + return None + if unit: + text = str(unit).strip().lower() + if text == "kw": + return round(number * 1000.0, 1) + if text == "w": + return round(number, 1) + if abs(number) < threshold: + return round(number * 1000.0, 1) + return round(number, 1) diff --git a/apps/predbat/tests/test_hanchu_api.py b/apps/predbat/tests/test_hanchu_api.py new file mode 100644 index 000000000..2c9361cfe --- /dev/null +++ b/apps/predbat/tests/test_hanchu_api.py @@ -0,0 +1,1039 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Hanchu ESS cloud API and auth +# ----------------------------------------------------------------------------- + +"""Tests for the Hanchu cloud component's request layer, auth and poll tiers (``hanchu.py``).""" + +import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) +import pytz +import time +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from hanchu import HanchuAPI +from hanchu_const import ( + HANCHU_KEY_CHARGE_POWER, + HANCHU_KEY_CHARGE_SOC, + HANCHU_KEY_WORK_MODE, + HANCHU_TOKEN_REFRESH_SECONDS, + HANCHU_WRITE_RETRY_DELAY, +) +from tests.test_infra import create_aiohttp_mock_response, create_aiohttp_mock_session, run_async as run_async_local + + +class MockHanchu(HanchuAPI): + """Test double: build a HanchuAPI without the full component lifecycle.""" + + def __init__(self, account="tester@example.com", password="hunter2", inverter_sn=None, control_enable=True, automatic=False, automatic_ignore_pv=False, work_mode_control=True, min_write_interval=0): + """Set up a minimal HanchuAPI instance for tests, bypassing ComponentBase.__init__.""" + self.prefix = "predbat" + self.log_messages = [] + self.local_tz = pytz.timezone("Europe/London") + self.base = MagicMock() + self.base.args = {"user_id": "test-hanchu-1"} + # A real clock, not a MagicMock attribute: ComponentBase.minutes_now derives from + # base.now_utc, so the mock base has to carry a coherent now_utc/midnight_utc pair. + # set_mock_clock() below moves it; writing base.minutes_now alone does nothing. + self.base.now_utc = datetime.now(pytz.utc).replace(hour=0, minute=0, second=0, microsecond=0) + self.base.midnight_utc = self.base.now_utc + self.base.minutes_now = 0 + self.state = {} + self.published = {} + # api_delay 0 rather than initialize()'s real default: it is a courtesy pause between + # consecutive calls to a real cloud, and nothing here talks to one - every request is a + # scripted mock. Left at 1 it would be seconds of dead wall-clock per multi-call test. + # min_write_interval defaults to 0 so a test exercises the write path rather than pacing; + # a pacing test passes its own value. + self.initialize( + account=account, + password=password, + inverter_sn=inverter_sn, + automatic=automatic, + automatic_ignore_pv=automatic_ignore_pv, + control_enable=control_enable, + work_mode_control=work_mode_control, + api_delay=0, + min_write_interval=min_write_interval, + ) + # The write-retry pause is a real 5s wait against a real cloud. Nothing here talks to one, + # so it is zeroed - a test that wants to prove the retry happens counts the requests, which + # the pause does not affect. test_hanchu_retry_delay_default asserts the shipped value. + self.write_retry_delay = 0 + + def set_mock_clock(self, minutes_now): + """Move the mock base's clock to minutes_now past midnight.""" + self.base.now_utc = self.base.midnight_utc + timedelta(minutes=minutes_now) + self.base.minutes_now = minutes_now + + def log(self, message): + """Capture logs.""" + self.log_messages.append(message) + + def update_success_timestamp(self): + """No-op for tests.""" + pass + + def dashboard_item(self, entity, state, attributes, app=None): + """Record a published entity instead of reaching Home Assistant.""" + self.published[entity] = {"state": state, "attributes": attributes} + self.state[entity] = state + + def get_state_wrapper(self, entity_id=None, default=None, attribute=None, refresh=False, required_unit=None, raw=False): + """Read back whatever the test (or dashboard_item) put in self.state.""" + return self.state.get(entity_id, default) + + def set_arg_auto(self, arg, value): + """Record an auto-discovered apps.yaml binding.""" + self.base.args[arg] = value + + def refresh_discovery(self): + """No-op: the discovery record has its own tests and needs no coordinator here.""" + pass + + @property + def storage(self): + """No Storage component in unit tests - matches a standalone CLI run.""" + return None + + +def envelope(data=None, success=True, code=200, msg=None): + """Build a Hanchu response envelope. + + success defaults to True only for code 200. The client treats `success` as the verdict, so a + helper that defaulted every envelope to success would make a failure envelope read as a success + and let tests pass for the wrong reason. + """ + if code != 200: + success = False + return {"code": code, "success": success, "msg": msg if msg is not None else ("Success" if success else "Failed"), "data": data} + + +def logged(client, fragment): + """Whether any captured log line contains fragment.""" + return any(fragment in message for message in client.log_messages) + + +def mock_json_response(status=200, json_data=None): + """Build a mock aiohttp response whose .json() tolerates hanchu.py's content_type kwarg. + + ``create_aiohttp_mock_response()``'s json() coroutine takes no arguments, but hanchu.py always + calls ``response.json(content_type=None)``: the Hanchu API does not reliably set an + application/json Content-Type header, so the real code asks aiohttp to parse the body whatever + the header says. The coroutine is rebound here to accept and ignore whatever kwargs it is + called with - the same fix test_sunsynk_api.py and test_sigenergy.py already apply for exactly + the same reason. + """ + response = create_aiohttp_mock_response(status=status, json_data=json_data) + + async def _json(*args, **kwargs): + """Return the canned JSON payload regardless of the content_type kwarg passed.""" + return {} if json_data is None else json_data + + response.json = _json + return response + + +def patched_session(responses): + """Patch aiohttp.ClientSession so the component's posts get `responses` in order.""" + mock_responses = [mock_json_response(status=status, json_data=body) for status, body in responses] + return patch("aiohttp.ClientSession", return_value=create_aiohttp_mock_session(mock_responses)) + + +DEVICE_LIST_SAMPLE = [ + {"sn": "HC240100001", "devType": "2"}, + {"sn": "HC240100002", "devType": "2"}, + # A non-DTU entry, which must be skipped: only device type 2 accepts the control endpoints. + {"sn": "BATTERY0001", "devType": "3"}, +] + +STATUS_SAMPLE = { + # A FRACTION, not a percent - the component multiplies by 100. + "batSoc": 0.62, + # Positive on CHARGE, in kW per its own unit sibling. Predbat wants negative on charge. + "batP": 2.4, + "batPUnit": "kW", + # Positive on IMPORT, in watts. Predbat wants negative on import. + "meterPPwr": 1500, + "meterPPwrUnit": "W", + "pvTtPwr": 3.1, + "pvTtPwrUnit": "kW", + "loadPwr": 900, + "bmsDesignCap": 10.24, + "stationId": "ST-9001", +} + +STATISTICS_SAMPLE = {"load": 12.5, "gridImport": 4.2, "gridExport": 1.1, "pv": 9.8, "batCharge": 5.5, "batDisCharge": 4.9} + +MENU_SAMPLE = { + "data": { + "energy": { + "items": [ + [ + {"itemCodeSignal": "CHG_PWR_LMT", "minVal": "0", "maxVal": "7000"}, + {"itemCodeSignal": "CHG_BAT_SOC_LMT", "minVal": "40", "maxVal": "100"}, + ], + [ + # An item for a key Predbat never writes must not create a range entry. + {"itemCodeSignal": "SOMETHING_ELSE", "minVal": "1", "maxVal": "2"}, + # Unparseable bounds are skipped rather than defaulted, because a wrong range + # silently clamps a good plan. + {"itemCodeSignal": "DSCHG_PWR_LMT", "minVal": "low", "maxVal": "high"}, + ], + ] + } + } +} + + +def test_hanchu_login_sends_the_account_and_stores_the_bare_token(): + """The login body is {account, pwd} and the token arrives as a bare string in `data`.""" + failed = False + client = MockHanchu() + with patched_session([(200, envelope(data="tok-abc123"))]) as session_cls: + ok = run_async_local(client.fetch_token()) + if not ok or client._token != "tok-abc123": + print(f"ERROR: login did not store the token: ok={ok} token={client._token!r}") + failed = True + call = session_cls.return_value.post.call_args + if call.args[0] != "https://iess3.hanchuess.com/gateway/identify/auth/token": + print(f"ERROR: login URL {call.args[0]}") + failed = True + if call.kwargs["json"] != {"account": "tester@example.com", "pwd": "hunter2"}: + print(f"ERROR: login body {call.kwargs['json']}") + failed = True + # The login itself is anonymous, so it must not carry a token header. + if "access-token" in call.kwargs["headers"]: + print("ERROR: the login request carried an access-token header") + failed = True + if call.kwargs["headers"].get("appPlat") != "ha": + print(f"ERROR: appPlat header {call.kwargs['headers'].get('appPlat')}") + failed = True + assert not failed, "test_hanchu_login_sends_the_account_and_stores_the_bare_token" + + +def test_hanchu_login_failure_names_the_credentials(): + """A rejected login points the user at the two config keys rather than at a trace.""" + failed = False + client = MockHanchu() + with patched_session([(200, envelope(success=False, code=100, msg="account or password error"))]): + ok = run_async_local(client.fetch_token()) + if ok or client._token: + print(f"ERROR: a rejected login must store no token: ok={ok} token={client._token!r}") + failed = True + if not logged(client, "hanchu_account"): + print("ERROR: the failure message should name hanchu_account") + failed = True + if client.last_api_error != "account or password error": + print(f"ERROR: last_api_error {client.last_api_error!r} should carry the API's own msg") + failed = True + assert not failed, "test_hanchu_login_failure_names_the_credentials" + + +def test_hanchu_password_is_never_logged(): + """The trace masks the password and the account e-mail, and nothing else in the envelope.""" + failed = False + client = MockHanchu(account="owner@example.com", password="super-secret-password") + with patched_session([(200, envelope(data="tok-abc123"))]): + run_async_local(client.fetch_token()) + joined = "\n".join(client.log_messages) + if "super-secret-password" in joined: + print("ERROR: the password reached the log") + failed = True + if "tok-abc123" in joined: + print("ERROR: the token reached the log") + failed = True + # Testers post these logs on GitHub, so the account e-mail is masked too. + if "owner@example.com" in joined: + print("ERROR: the account e-mail reached the log") + failed = True + # The trace has to remain useful: nobody on the project has Hanchu hardware, so a tester's log + # is the only evidence there is. + if not logged(client, "identify/auth/token"): + print("ERROR: the request itself was not traced at all") + failed = True + assert not failed, "test_hanchu_password_is_never_logged" + + +def test_hanchu_token_refresh_falls_back_to_a_full_login(): + """A refusal to refresh logs in again rather than giving up - Predbat holds the credentials. + + The reference integration has to ask Home Assistant to re-authenticate here, because it holds + only a token. Predbat holds the account itself, so this path is always recoverable. + """ + failed = False + client = MockHanchu() + client._token = "tok-old" + client._token_time = time.time() - HANCHU_TOKEN_REFRESH_SECONDS - 1 + with patched_session([(200, envelope(success=False, code=100, msg="token invalid")), (200, envelope(data="tok-new"))]): + ok = run_async_local(client.refresh_token()) + if not ok or client._token != "tok-new": + print(f"ERROR: refresh fallback did not re-login: ok={ok} token={client._token!r}") + failed = True + if not logged(client, "logging in again"): + print("ERROR: the fallback to a full login was not explained") + failed = True + assert not failed, "test_hanchu_token_refresh_falls_back_to_a_full_login" + + +def test_hanchu_refresh_is_suppressed_inside_its_window(): + """A burst of expiries cannot become a refresh storm.""" + failed = False + client = MockHanchu() + client._token = "tok-old" + client._last_refresh_attempt = time.time() + with patched_session([(200, envelope(data="tok-new"))]) as session_cls: + ok = run_async_local(client.refresh_token()) + if not ok: + print("ERROR: a suppressed refresh should still report the held token as usable") + failed = True + if session_cls.return_value.post.call_count: + print(f"ERROR: {session_cls.return_value.post.call_count} request(s) went out inside the suppression window") + failed = True + if client._token != "tok-old": + print(f"ERROR: token changed to {client._token!r} without a request") + failed = True + assert not failed, "test_hanchu_refresh_is_suppressed_inside_its_window" + + +def test_hanchu_ensure_token_refreshes_only_when_due(): + """A fresh token is left alone; an old one is refreshed; none at all triggers a login.""" + failed = False + client = MockHanchu() + client._token = "tok-fresh" + client._token_time = time.time() + with patched_session([]) as session_cls: + ok = run_async_local(client.ensure_token()) + if not ok or session_cls.return_value.post.call_count: + print(f"ERROR: a fresh token should need no request: ok={ok} calls={session_cls.return_value.post.call_count}") + failed = True + + client = MockHanchu() + with patched_session([(200, envelope(data="tok-first"))]) as session_cls: + ok = run_async_local(client.ensure_token()) + if not ok or client._token != "tok-first": + print(f"ERROR: no token should trigger a login: ok={ok} token={client._token!r}") + failed = True + if session_cls.return_value.post.call_args.args[0].endswith("/refresh"): + print("ERROR: with no token at all the component must log in, not call refresh") + failed = True + assert not failed, "test_hanchu_ensure_token_refreshes_only_when_due" + + +def test_hanchu_401_inside_a_200_triggers_one_reauth_and_one_retry(): + """This API answers an expired session as HTTP 200 carrying code 401, not only as a status.""" + failed = False + client = MockHanchu() + client._token = "tok-old" + client._token_time = time.time() + responses = [ + # The real call, refused for an expired session. + (200, envelope(success=False, code=401)), + # The re-login. + (200, envelope(data="tok-new")), + # The retry, which succeeds. + (200, envelope(data=DEVICE_LIST_SAMPLE)), + ] + with patched_session(responses) as session_cls: + serials = run_async_local(client.get_device_list()) + if serials != ["HC240100001", "HC240100002"]: + print(f"ERROR: the retry after re-auth did not return the device list: {serials}") + failed = True + if session_cls.return_value.post.call_count != 3: + print(f"ERROR: expected 3 requests (call, re-auth, retry), got {session_cls.return_value.post.call_count}") + failed = True + # The retry has to carry the NEW token, not the dead one. + if session_cls.return_value.post.call_args.kwargs["headers"].get("access-token") != "tok-new": + print(f"ERROR: the retry carried {session_cls.return_value.post.call_args.kwargs['headers'].get('access-token')!r}") + failed = True + assert not failed, "test_hanchu_401_inside_a_200_triggers_one_reauth_and_one_retry" + + +def test_hanchu_reauth_is_attempted_only_once_per_call(): + """A persistent 401 must not loop between the call and the re-auth.""" + failed = False + client = MockHanchu() + client._token = "tok-old" + client._token_time = time.time() + responses = [ + (200, envelope(success=False, code=401)), + (200, envelope(data="tok-new")), + # Still refused after the re-auth: the component must give up rather than re-auth again. + (200, envelope(success=False, code=401)), + ] + with patched_session(responses) as session_cls: + ok, _data = run_async_local(client._post("device_list")) + if ok: + print("ERROR: a persistent 401 should not report success") + failed = True + if session_cls.return_value.post.call_count != 3: + print(f"ERROR: expected exactly 3 requests, got {session_cls.return_value.post.call_count} - the re-auth loop is not capped") + failed = True + assert not failed, "test_hanchu_reauth_is_attempted_only_once_per_call" + + +def test_hanchu_device_list_keeps_only_the_dtu_and_honours_the_filter(): + """Only device type 2 accepts the control endpoints; anything else is named and skipped.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + with patched_session([(200, envelope(data=DEVICE_LIST_SAMPLE))]): + serials = run_async_local(client.get_device_list()) + if serials != ["HC240100001", "HC240100002"]: + print(f"ERROR: device list {serials}") + failed = True + if not logged(client, "BATTERY0001"): + print("ERROR: the skipped non-DTU entry was not named in the log") + failed = True + if client.discovery_ok is not True: + print(f"ERROR: discovery_ok {client.discovery_ok}") + failed = True + + client = MockHanchu(inverter_sn="HC240100002") + client._token = "tok" + client._token_time = time.time() + with patched_session([(200, envelope(data=DEVICE_LIST_SAMPLE))]): + serials = run_async_local(client.get_device_list()) + if serials != ["HC240100002"]: + print(f"ERROR: filtered device list {serials}") + failed = True + assert not failed, "test_hanchu_device_list_keeps_only_the_dtu_and_honours_the_filter" + + +def test_hanchu_failed_discovery_keeps_the_known_devices(): + """A transient discovery failure must not take a working component down. Absence is not a result.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + with patched_session([(200, envelope(success=False, code=100, msg="server busy"))]): + ok = run_async_local(client.refresh_static()) + if ok: + print("ERROR: a failed discovery must not report success") + failed = True + if client.device_list != ["HC240100001"]: + print(f"ERROR: the known device list was lost: {client.device_list}") + failed = True + if client.tier_expired("static", 480) is not True: + print("ERROR: the static tier clock must not advance on a failure, or a restart skips re-discovery") + failed = True + assert not failed, "test_hanchu_failed_discovery_keeps_the_known_devices" + + +def test_hanchu_empty_account_is_distinguished_from_a_failed_call(): + """Both produce an empty list, so discovery_ok is what tells them apart.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + with patched_session([(200, envelope(data=[]))]): + ok = run_async_local(client.refresh_static()) + if ok: + print("ERROR: an empty account is not a successful startup") + failed = True + if client.discovery_ok is not True: + print("ERROR: discovery_ok should be True - the call itself worked") + failed = True + if not logged(client, "no DTU devices"): + print("ERROR: the empty account was not explained") + failed = True + assert not failed, "test_hanchu_empty_account_is_distinguished_from_a_failed_call" + + +def test_hanchu_status_payload_applies_scale_units_and_sign(): + """SoC is a fraction, powers carry their own units, and two fields have Predbat's sign flipped.""" + failed = False + client = MockHanchu() + if not client._apply_status_payload("HC240100001", STATUS_SAMPLE): + print("ERROR: a payload with a SoC should be accepted") + failed = True + values = client.device_values.get("HC240100001", {}) + expected = { + # 0.62 * 100 + "soc": 62.0, + # 2.4 kW positive-on-charge becomes -2400 W, which is Predbat's convention + "battery_power": -2400.0, + # 1500 W positive-on-import becomes -1500 W + "grid_power": -1500.0, + # 3.1 kW, not negated + "pv_power": 3100.0, + # 900 with no unit sibling: above the heuristic threshold, so watts + "load_power": 900.0, + } + for leaf, value in expected.items(): + if values.get(leaf) != value: + print(f"ERROR: {leaf} = {values.get(leaf)} != {value}") + failed = True + detail = client.device_detail.get("HC240100001", {}) + if detail.get("capacity_kwh") != 10.24: + print(f"ERROR: capacity {detail.get('capacity_kwh')} != 10.24") + failed = True + if detail.get("station_id") != "ST-9001": + print(f"ERROR: station {detail.get('station_id')} != ST-9001") + failed = True + # No SoC means nothing Predbat can plan with, so the payload is refused outright. + if client._apply_status_payload("HC240100002", {"batP": 1.0}): + print("ERROR: a payload with no SoC should be refused") + failed = True + assert not failed, "test_hanchu_status_payload_applies_scale_units_and_sign" + + +def test_hanchu_energy_counters_are_mapped(): + """The six daily counters land under the Predbat leaf names automatic_config binds.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + with patched_session([(200, envelope(data=STATISTICS_SAMPLE))]): + ok = run_async_local(client.refresh_energy()) + if not ok: + print("ERROR: refresh_energy reported failure") + failed = True + energy = client.device_energy.get("HC240100001", {}) + for leaf, value in (("load_today", 12.5), ("import_today", 4.2), ("export_today", 1.1), ("pv_today", 9.8), ("battery_charge_today", 5.5), ("battery_discharge_today", 4.9)): + if energy.get(leaf) != value: + print(f"ERROR: {leaf} = {energy.get(leaf)} != {value}") + failed = True + assert not failed, "test_hanchu_energy_counters_are_mapped" + + +def test_hanchu_menu_ranges_override_the_defaults(): + """The menu's own per-device bounds win, and an unparseable or unknown item is ignored.""" + failed = False + client = MockHanchu() + ranges = client._parse_menu_ranges(MENU_SAMPLE) + if ranges.get(HANCHU_KEY_CHARGE_POWER) != (0, 7000): + print(f"ERROR: menu charge power range {ranges.get(HANCHU_KEY_CHARGE_POWER)} != (0, 7000)") + failed = True + if ranges.get(HANCHU_KEY_CHARGE_SOC) != (40, 100): + print(f"ERROR: menu charge SoC range {ranges.get(HANCHU_KEY_CHARGE_SOC)} != (40, 100)") + failed = True + if "SOMETHING_ELSE" in ranges: + print("ERROR: a menu item for a key Predbat never writes created a range") + failed = True + if "DSCHG_PWR_LMT" in ranges: + print("ERROR: unparseable bounds should be skipped, not accepted") + failed = True + # ranges_for merges the menu over the built-in defaults, so the unparseable key keeps its + # default and the derived ratings follow the menu where it spoke. + client.device_ranges["HC240100001"] = ranges + merged = client.ranges_for("HC240100001") + if merged.get(HANCHU_KEY_CHARGE_POWER) != (0, 7000) or merged.get("DSCHG_PWR_LMT") != (0, 5000): + print(f"ERROR: merged ranges {merged.get(HANCHU_KEY_CHARGE_POWER)} {merged.get('DSCHG_PWR_LMT')}") + failed = True + if client.battery_rate_max("HC240100001") != 7000.0: + print(f"ERROR: battery_rate_max {client.battery_rate_max('HC240100001')} should follow the menu's charge power ceiling") + failed = True + if client.inverter_limit("HC240100001") != 5000.0: + print(f"ERROR: inverter_limit {client.inverter_limit('HC240100001')} should follow the discharge power ceiling") + failed = True + # Junk must not raise inside the poll loop. + for junk in (None, {}, {"data": None}, {"data": {"energy": []}}): + if client._parse_menu_ranges(junk) != {}: + print(f"ERROR: _parse_menu_ranges({junk!r}) should be empty") + failed = True + assert not failed, "test_hanchu_menu_ranges_override_the_defaults" + + +def test_hanchu_battery_rate_max_override_wins(): + """A user who knows their pack's real limit overrides the derived one.""" + failed = False + client = MockHanchu() + client.battery_rate_max_override = 3600.0 + if client.battery_rate_max("HC240100001") != 3600.0: + print(f"ERROR: override ignored: {client.battery_rate_max('HC240100001')}") + failed = True + assert not failed, "test_hanchu_battery_rate_max_override_wins" + + +def test_hanchu_inverter_limit_override_wins(): + """Hanchu units come in 3.68, 5, 6 and 10 kW; a user who knows theirs overrides the derived one.""" + failed = False + client = MockHanchu() + client.device_ranges["HC240100001"] = {"DSCHG_PWR_LMT": (0, 5000)} + if client.inverter_limit("HC240100001") != 5000.0: + print(f"ERROR: derived limit wrong: {client.inverter_limit('HC240100001')}") + failed = True + client.inverter_limit_override = 3680.0 + if client.inverter_limit("HC240100001") != 3680.0: + print(f"ERROR: override ignored: {client.inverter_limit('HC240100001')}") + failed = True + assert not failed, "test_hanchu_inverter_limit_override_wins" + + +def test_hanchu_default_power_limit_is_warned_once(): + """A menu that answers without usable power bounds leaves the 5 kW default in force - say so, once.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + # MENU_SAMPLE carries a charge power bound but an unparseable discharge one. + for _ in range(2): + with patched_session([(200, envelope(data=MENU_SAMPLE["data"])), (200, envelope(data={}))]): + run_async_local(client.refresh_config()) + warnings = [message for message in client.log_messages if "did not report its power limits" in message] + if len(warnings) != 1 or "DSCHG_PWR_LMT" not in warnings[0] or "hanchu_inverter_limit" not in warnings[0]: + print(f"ERROR: expected one warning naming DSCHG_PWR_LMT and the override, got {warnings}") + failed = True + # A device whose menu reports both bounds is not warned about. + quiet = MockHanchu() + quiet._warn_if_default_power("HC240100002", {"CHG_PWR_LMT": (0, 6000), "DSCHG_PWR_LMT": (0, 6000)}) + if logged(quiet, "did not report its power limits"): + print("ERROR: warned although both power bounds were reported") + failed = True + assert not failed, "test_hanchu_default_power_limit_is_warned_once" + + +def test_hanchu_menu_answered_by_code_alone_is_used(): + """The menu answers code 200 with no success flag; its ranges must still be applied (batpred#5305, hardware). + + The real envelope is {"code": 200, "msg": ..., "data": {"energy": {...}}} - no success key - and + the parser receives the inner data, not the whole envelope. Either half wrong and every device + silently runs on the 5 kW defaults. + """ + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + menu = {"code": 200, "msg": "success", "data": MENU_SAMPLE["data"]} + with patched_session([(200, menu), (200, envelope(data={"WORK_MODE_CMB": "3"}))]): + run_async_local(client.refresh_config()) + ranges = client.device_ranges.get("HC240100001", {}) + if ranges.get(HANCHU_KEY_CHARGE_POWER) != (0, 7000) or ranges.get(HANCHU_KEY_CHARGE_SOC) != (40, 100): + print(f"ERROR: the menu's ranges were not applied: {ranges}") + failed = True + if logged(client, "/gateway/app/ha/menu was unsuccessful"): + print("ERROR: a code-200 menu was logged as a failure") + failed = True + if client.battery_rate_max("HC240100001") != 7000.0: + print(f"ERROR: battery_rate_max {client.battery_rate_max('HC240100001')} should follow the menu") + failed = True + assert not failed, "test_hanchu_menu_answered_by_code_alone_is_used" + + +def test_hanchu_code_alone_is_success_only_for_the_menu(): + """Every other endpoint still needs success: true - a bare code 200 there is not taken as success.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + with patched_session([(200, {"code": 200, "msg": "?", "data": DEVICE_LIST_SAMPLE})]): + serials = run_async_local(client.get_device_list()) + if serials or client.discovery_ok is not False: + print(f"ERROR: a device list without success was accepted: {serials} discovery_ok={client.discovery_ok}") + failed = True + assert not failed, "test_hanchu_code_alone_is_success_only_for_the_menu" + + +def test_hanchu_failed_menu_warns_the_default_power_is_in_use(): + """A menu call that fails leaves the 5 kW default in force too, so it is warned about, once.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + for _ in range(2): + with patched_session([(200, envelope(code=500, msg="busy")), (200, envelope(data={}))]): + run_async_local(client.refresh_config()) + warnings = [message for message in client.log_messages if "did not report its power limits" in message] + if len(warnings) != 1: + print(f"ERROR: expected one default-power warning after a failed menu, got {warnings}") + failed = True + assert not failed, "test_hanchu_failed_menu_warns_the_default_power_is_in_use" + + +def test_hanchu_ac_coupled_pv_is_counted_in_pv_power(): + """On an AC-coupled system the solar is only in bypMeterTotalPower, so pv_power must include it (batpred#5305, hardware). + + bypMeterSwitch is 1 on AC-coupled systems and 0 on DC-coupled ones. The readings are upton68's + from a real AC-coupled unit and a contributor's DC-coupled one. + """ + failed = False + client = MockHanchu() + ac_coupled = {"batSoc": 0.5, "batP": 408.0, "batPUnit": "W", "meterPPwr": 0, "meterPPwrUnit": "W", "pvTtPwr": 0.0, "pvTtPwrUnit": "W", "bypMeterTotalPower": 686.8, "bypMeterTotalPowerUnit": "W", "bypMeterSwitch": 1, "loadPwr": 278.8} + client._apply_status_payload("HC1", ac_coupled) + values = client.device_values["HC1"] + if values.get("pv_power") != 686.8 or values.get("ac_pv_power") != 686.8: + print(f"ERROR: AC-coupled pv_power {values.get('pv_power')} ac_pv_power {values.get('ac_pv_power')}, expected both 686.8") + failed = True + dc_coupled = dict(ac_coupled, pvTtPwr=15.0, bypMeterTotalPower=0, bypMeterSwitch=0) + client._apply_status_payload("HC2", dc_coupled) + if client.device_values["HC2"].get("pv_power") != 15.0: + print(f"ERROR: DC-coupled pv_power {client.device_values['HC2'].get('pv_power')}, expected 15.0") + failed = True + # A bypass reading on a unit that says it has no bypass meter is not added: the switch decides. + stray = dict(dc_coupled, bypMeterTotalPower=500.0) + client._apply_status_payload("HC3", stray) + if client.device_values["HC3"].get("pv_power") != 15.0: + print(f"ERROR: pv_power {client.device_values['HC3'].get('pv_power')} took a bypass reading with bypMeterSwitch 0") + failed = True + # With no switch reported at all, behaviour is unchanged. + unknown = {key: value for key, value in ac_coupled.items() if key != "bypMeterSwitch"} + client._apply_status_payload("HC4", unknown) + if client.device_values["HC4"].get("pv_power") != 0.0: + print(f"ERROR: pv_power {client.device_values['HC4'].get('pv_power')} changed without a bypMeterSwitch") + failed = True + assert not failed, "test_hanchu_ac_coupled_pv_is_counted_in_pv_power" + + +# The phone app's menu as a device using the TCT_/TDT_ slot keys describes it: a time item carries both +# of its signals comma-separated (the vendor card splits them), and its listener names the work modes +# the slots are shown - and so run - in. +MENU_SLOTS = { + "energy": { + "items": [ + [ + {"itemCodeSignal": "WORK_MODE_CMB", "itemType": "3"}, + {"itemCode": "chg_time_1", "itemCodeSignal": "TCT_START_1,TCT_END_1", "itemType": "6", "listener": {"code": "WORK_MODE_CMB", "show": "3"}}, + {"itemCode": "dschg_time_1", "itemCodeSignal": "TDT_START_1,TDT_END_1", "itemType": "6", "listener": {"code": "WORK_MODE_CMB", "show": "3"}}, + ] + ] + } +} + +# The other slot format the vendor card handles: array-valued period items (types 82/83) and a +# FLAG_ENABLE_CYCLE enable list, with no TCT_/TDT_ keys at all. +MENU_ARRAY_SLOTS = { + "energy": { + "items": [ + [ + {"itemCode": "TCT_CHG1", "itemCodeSignal": "CHG_PERIOD_1", "itemType": "82", "structure": [{"itemCode": "charge_mode"}, {"itemCode": "chg_pwr_lmt"}, {"itemCode": "start_time"}, {"itemCode": "end_time"}]}, + {"itemCodeSignal": "FLAG_ENABLE_CYCLE", "itemType": "4"}, + ] + ] + } +} + + +def ready_client(**kwargs): + """Return a MockHanchu past login with one device, ready to poll.""" + client = MockHanchu(**kwargs) + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + return client + + +def test_hanchu_menu_slot_model_is_recognised(): + """The menu says which slot format a device uses, and which work modes run the TCT_/TDT_ slots.""" + failed = False + client = ready_client() + with patched_session([(200, {"code": 200, "data": MENU_SLOTS}), (200, envelope(data={}))]): + run_async_local(client.refresh_config()) + if client.slot_model.get("HC240100001") != "slots" or client.slot_work_modes.get("HC240100001") != [3]: + print(f"ERROR: slot model {client.slot_model} modes {client.slot_work_modes}, expected slots / [3]") + failed = True + if client.slot_work_mode("HC240100001") != 3: + print(f"ERROR: slot work mode {client.slot_work_mode('HC240100001')}, expected 3") + failed = True + + array = ready_client() + with patched_session([(200, {"code": 200, "data": MENU_ARRAY_SLOTS}), (200, envelope(data={}))]): + run_async_local(array.refresh_config()) + if array.slot_model.get("HC240100001") != "array": + print(f"ERROR: an array-slot menu read as {array.slot_model}") + failed = True + + # A menu showing neither format is unknown, not unsupported: control stays allowed. + unknown = ready_client() + with patched_session([(200, {"code": 200, "data": MENU_SAMPLE["data"]}), (200, envelope(data={}))]): + run_async_local(unknown.refresh_config()) + if unknown.slot_model.get("HC240100001") not in (None, "unknown"): + print(f"ERROR: a menu with no slot items read as {unknown.slot_model}") + failed = True + assert not failed, "test_hanchu_menu_slot_model_is_recognised" + + +def test_hanchu_slot_work_mode_follows_the_menu(): + """User-defined (3) is written where the menu allows it or says nothing; otherwise the menu's own mode is, and that is logged.""" + failed = False + client = ready_client() + if client.slot_work_mode("HC240100001") != 3: + print(f"ERROR: with no menu the slot work mode is {client.slot_work_mode('HC240100001')}, expected 3") + failed = True + client.slot_work_modes["HC240100001"] = [3, 4] + if client.slot_work_mode("HC240100001") != 3: + print("ERROR: 3 is listed, so it should be kept") + failed = True + client.slot_work_modes["HC240100001"] = [4] + if client.slot_work_mode("HC240100001") != 4 or not logged(client, "work mode 4"): + print(f"ERROR: the menu only runs the slots in mode 4, got {client.slot_work_mode('HC240100001')}; logs {client.log_messages[-2:]}") + failed = True + payload = client.build_write_payload("HC240100001", {"reserve": 10, "charge": {"enable": True, "soc": 80, "power": 2000, "start": "01:00:00", "end": "02:00:00"}, "export": {"enable": False}}) + if payload.get(HANCHU_KEY_WORK_MODE) != 4: + print(f"ERROR: payload work mode {payload.get(HANCHU_KEY_WORK_MODE)}, expected the menu's 4") + failed = True + assert not failed, "test_hanchu_slot_work_mode_follows_the_menu" + + +def test_hanchu_array_slot_device_is_not_controlled(): + """A device on the array slot format would accept TCT_ writes and ignore them, so nothing is written - and the user is told once.""" + failed = False + client = ready_client() + client.slot_model["HC240100001"] = "array" + client.local_schedule["HC240100001"] = {"reserve": 10, "charge": {"enable": True, "soc": 80, "power": 2000, "start": "01:00:00", "end": "02:00:00"}, "export": {"enable": False}} + with patched_session([(200, envelope(data={}))]) as session_cls: + first = run_async_local(client.apply_schedule("HC240100001")) + second = run_async_local(client.apply_schedule("HC240100001")) + if first or second or session_cls.return_value.post.call_count: + print(f"ERROR: an array-slot device was written to: {first} {second} calls={session_cls.return_value.post.call_count}") + failed = True + warnings = [message for message in client.log_messages if "different time-slot format" in message] + if len(warnings) != 1: + print(f"ERROR: expected one warning, got {warnings}") + failed = True + if "time-slot format" not in (client.health_message() or ""): + print(f"ERROR: health message {client.health_message()!r} should name the unsupported slot format") + failed = True + assert not failed, "test_hanchu_array_slot_device_is_not_controlled" + + +def test_hanchu_offline_device_is_reported(): + """devStatus 0 means the datalogger is offline and the readings are stale - say so, once per outage.""" + failed = False + client = ready_client() + client._apply_status_payload("HC240100001", dict(STATUS_SAMPLE, devStatus=1)) + if client.device_online.get("HC240100001") is not True or client.health_message(): + print(f"ERROR: online device: {client.device_online} health {client.health_message()!r}") + failed = True + for _ in range(2): + client._apply_status_payload("HC240100001", dict(STATUS_SAMPLE, devStatus=0)) + if client.device_online.get("HC240100001") is not False or "offline" not in (client.health_message() or ""): + print(f"ERROR: offline device: {client.device_online} health {client.health_message()!r}") + failed = True + if len([message for message in client.log_messages if "is offline" in message]) != 1: + print(f"ERROR: the outage should be logged once: {client.log_messages}") + failed = True + client._apply_status_payload("HC240100001", dict(STATUS_SAMPLE, devStatus=1)) + if client.health_message() or not logged(client, "is back online"): + print(f"ERROR: recovery not reported: health {client.health_message()!r}") + failed = True + # 99 is pending, and no devStatus at all is unknown: neither is called offline. + client._apply_status_payload("HC240100001", dict(STATUS_SAMPLE, devStatus=99)) + if client.health_message(): + print(f"ERROR: pending read as offline: {client.health_message()!r}") + failed = True + assert not failed, "test_hanchu_offline_device_is_reported" + + +def test_hanchu_http_session_is_reused_and_closed(): + """One open session serves every call (upton68 2.0.2: far fewer timeouts than a session per call), and final() closes it.""" + failed = False + client = ready_client() + session = create_aiohttp_mock_session([mock_json_response(json_data=envelope(data=STATUS_SAMPLE)), mock_json_response(json_data=envelope(data=STATISTICS_SAMPLE))]) + session.closed = False + session.close = AsyncMock() + with patch("aiohttp.ClientSession", return_value=session) as session_cls: + run_async_local(client.refresh_power()) + run_async_local(client.refresh_energy()) + if session_cls.call_count != 1 or session.post.call_count != 2: + print(f"ERROR: {session_cls.call_count} sessions for {session.post.call_count} calls, expected 1 for 2") + failed = True + run_async_local(client.final()) + if not session.close.await_count: + print("ERROR: final() did not close the session") + failed = True + assert not failed, "test_hanchu_http_session_is_reused_and_closed" + + +def test_hanchu_generator_fields_are_read(): + """The generator's live power and daily energy are read and published, so a live test can show what they mean.""" + failed = False + client = ready_client() + client._apply_status_payload("HC240100001", dict(STATUS_SAMPLE, hasDg=True, dgPAcTotal=1.2, dgPAcTotalUnit="kW")) + if client.device_values["HC240100001"].get("generator_power") != 1200.0 or client.device_detail["HC240100001"].get("has_generator") is not True: + print(f"ERROR: generator status {client.device_values['HC240100001']} {client.device_detail['HC240100001']}") + failed = True + with patched_session([(200, envelope(data=dict(STATISTICS_SAMPLE, dgEp=3.4)))]): + run_async_local(client.refresh_energy()) + if client.device_energy["HC240100001"].get("generator_today") != 3.4: + print(f"ERROR: generator energy {client.device_energy['HC240100001']}") + failed = True + run_async_local(client.publish_data()) + for leaf in ("generator_power", "generator_today"): + if client._sensor_name("HC240100001", leaf) not in client.published: + print(f"ERROR: {leaf} was not published") + failed = True + assert not failed, "test_hanchu_generator_fields_are_read" + + +def test_hanchu_power_limit_follows_a_higher_value_the_device_holds(): + """The menu's 0-5000 W power range is shared by 2-10 kW models, so a higher value the device already holds wins. + + Hardware (batpred#5305): a HESS-HY-S-6.0K, nameplate 6000 W grid output, holds DSCHG_PWR_LMT 6000 + while its menu says 0-5000. Taken at the menu's word, inverter_limit came out at 5000 and every + discharge would be capped below what the inverter does. + """ + failed = False + client = ready_client() + sn = "HC240100001" + client.device_ranges[sn] = {"CHG_PWR_LMT": (0, 5000), "DSCHG_PWR_LMT": (0, 5000)} + client.device_settings[sn] = {"CHG_PWR_LMT": "5000", "DSCHG_PWR_LMT": "6000", "DSCHG_BAT_SOC_LMT": "10.00"} + ranges = client.ranges_for(sn) + if ranges.get("DSCHG_PWR_LMT") != (0, 6000) or ranges.get("CHG_PWR_LMT") != (0, 5000): + print(f"ERROR: power ranges {ranges.get('CHG_PWR_LMT')} / {ranges.get('DSCHG_PWR_LMT')}, expected 0-5000 / 0-6000") + failed = True + if client.inverter_limit(sn) != 6000.0 or client.battery_rate_max(sn) != 5000.0: + print(f"ERROR: inverter_limit {client.inverter_limit(sn)} battery_rate_max {client.battery_rate_max(sn)}, expected 6000 / 5000") + failed = True + client.ranges_for(sn) + raised = [message for message in client.log_messages if "above the menu" in message] + if len(raised) != 1 or "6000" not in raised[0]: + print(f"ERROR: the raise should be logged once, naming 6000: {raised}") + failed = True + # A held value below the menu's maximum leaves the menu's range alone. + client.device_settings[sn]["DSCHG_PWR_LMT"] = "3000" + if client.ranges_for(sn).get("DSCHG_PWR_LMT") != (0, 5000): + print(f"ERROR: a lower held value changed the range: {client.ranges_for(sn).get('DSCHG_PWR_LMT')}") + failed = True + # Each published power control advertises its own direction's maximum. + client.device_settings[sn]["DSCHG_PWR_LMT"] = "6000" + run_async_local(client.publish_schedule_settings_ha(sn)) + charge = client.published.get(client._control_name("number", sn, "battery_schedule_charge_power"), {}).get("attributes", {}) + export = client.published.get(client._control_name("number", sn, "battery_schedule_export_power"), {}).get("attributes", {}) + if charge.get("max") != 5000 or export.get("max") != 6000: + print(f"ERROR: power controls advertise charge max {charge.get('max')} export max {export.get('max')}, expected 5000 / 6000") + failed = True + assert not failed, "test_hanchu_power_limit_follows_a_higher_value_the_device_holds" + + +def test_hanchu_config_read_is_one_call_per_device(): + """Nineteen keys are read in ONE iotGet - the same batching rationale as the write path.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + client.device_list = ["HC240100001"] + settings = {"WORK_MODE_CMB": "3", "CHG_PWR_LMT": "2500", "TCT_START_1": 3600, "TCT_END_1": 18000} + with patched_session([(200, envelope(data=MENU_SAMPLE["data"])), (200, envelope(data=settings))]) as session_cls: + ok = run_async_local(client.refresh_config()) + if not ok: + print("ERROR: refresh_config reported failure") + failed = True + # Two calls only: one menu, one iotGet. Not one call per key. + if session_cls.return_value.post.call_count != 2: + print(f"ERROR: expected 2 requests (menu, iotGet), got {session_cls.return_value.post.call_count}") + failed = True + iot_call = session_cls.return_value.post.call_args + body = iot_call.kwargs["json"] + if body.get("devType") != "2" or body.get("sn") != "HC240100001": + print(f"ERROR: iotGet body {body}") + failed = True + if len(body.get("keys") or []) != 18: + print(f"ERROR: iotGet asked for {len(body.get('keys') or [])} keys, expected all 18 in one call") + failed = True + if client.device_settings.get("HC240100001", {}).get("WORK_MODE_CMB") != "3": + print(f"ERROR: settings not stored: {client.device_settings}") + failed = True + assert not failed, "test_hanchu_config_read_is_one_call_per_device" + + +def test_hanchu_transport_failure_is_retried_then_reported(): + """A transport error retries within its budget and then fails closed, never raising.""" + failed = False + client = MockHanchu() + client._token = "tok" + client._token_time = time.time() + with patch("aiohttp.ClientSession", return_value=create_aiohttp_mock_session(exception=OSError("connection reset"))) as session_cls: + ok, data = run_async_local(client._post("device_list")) + if ok or data is not None: + print(f"ERROR: a transport failure should fail closed: ok={ok} data={data!r}") + failed = True + if session_cls.return_value.post.call_count != 3: + print(f"ERROR: expected 3 transport attempts, got {session_cls.return_value.post.call_count}") + failed = True + if not logged(client, "transport failure"): + print("ERROR: the transport failure was not logged") + failed = True + assert not failed, "test_hanchu_transport_failure_is_retried_then_reported" + + +def test_hanchu_run_refuses_to_start_without_credentials(): + """No account or password is a configuration error named in the log, not a crash.""" + failed = False + client = MockHanchu(account="", password="") + ok = run_async_local(client.run(0, True)) + if ok: + print("ERROR: run() should defer startup with no credentials") + failed = True + if not logged(client, "hanchu_account"): + print("ERROR: run() did not name the missing config keys") + failed = True + if client.health_message() is None: + print("ERROR: health_message should explain the missing credentials") + failed = True + assert not failed, "test_hanchu_run_refuses_to_start_without_credentials" + + +def test_hanchu_retry_delay_default(): + """MockHanchu zeroes the write-retry pause, so the shipped value is pinned here instead. + + Without this the tests would be free of the pause AND free of any check on it, and it could + quietly become zero in production - turning the one retry into an immediate re-post at a device + that has just said it was busy. + """ + failed = False + client = HanchuAPI.__new__(HanchuAPI) + client.log = lambda message: None + client.initialize(account="a@b.c", password="p") + if client.write_retry_delay != HANCHU_WRITE_RETRY_DELAY or not HANCHU_WRITE_RETRY_DELAY: + print(f"ERROR: the shipped write retry delay is {client.write_retry_delay}, expected a non-zero {HANCHU_WRITE_RETRY_DELAY}") + failed = True + assert not failed, "test_hanchu_retry_delay_default" + + +def run_hanchu_api_tests(my_predbat): + """Run all Hanchu API and auth tests.""" + failed = False + for name, fn in [ + ("login", test_hanchu_login_sends_the_account_and_stores_the_bare_token), + ("login_failure", test_hanchu_login_failure_names_the_credentials), + ("password_redaction", test_hanchu_password_is_never_logged), + ("refresh_fallback", test_hanchu_token_refresh_falls_back_to_a_full_login), + ("refresh_suppression", test_hanchu_refresh_is_suppressed_inside_its_window), + ("ensure_token", test_hanchu_ensure_token_refreshes_only_when_due), + ("reauth_retry", test_hanchu_401_inside_a_200_triggers_one_reauth_and_one_retry), + ("reauth_capped", test_hanchu_reauth_is_attempted_only_once_per_call), + ("device_list", test_hanchu_device_list_keeps_only_the_dtu_and_honours_the_filter), + ("discovery_failure", test_hanchu_failed_discovery_keeps_the_known_devices), + ("empty_account", test_hanchu_empty_account_is_distinguished_from_a_failed_call), + ("status_payload", test_hanchu_status_payload_applies_scale_units_and_sign), + ("energy", test_hanchu_energy_counters_are_mapped), + ("menu_ranges", test_hanchu_menu_ranges_override_the_defaults), + ("rate_max_override", test_hanchu_battery_rate_max_override_wins), + ("inverter_limit_override", test_hanchu_inverter_limit_override_wins), + ("default_power_warning", test_hanchu_default_power_limit_is_warned_once), + ("config_batched_read", test_hanchu_config_read_is_one_call_per_device), + ("menu_code_alone", test_hanchu_menu_answered_by_code_alone_is_used), + ("code_alone_menu_only", test_hanchu_code_alone_is_success_only_for_the_menu), + ("failed_menu_warning", test_hanchu_failed_menu_warns_the_default_power_is_in_use), + ("ac_coupled_pv", test_hanchu_ac_coupled_pv_is_counted_in_pv_power), + ("slot_model", test_hanchu_menu_slot_model_is_recognised), + ("slot_work_mode", test_hanchu_slot_work_mode_follows_the_menu), + ("array_slots_blocked", test_hanchu_array_slot_device_is_not_controlled), + ("offline", test_hanchu_offline_device_is_reported), + ("session_reuse", test_hanchu_http_session_is_reused_and_closed), + ("generator", test_hanchu_generator_fields_are_read), + ("held_power_limit", test_hanchu_power_limit_follows_a_higher_value_the_device_holds), + ("transport_failure", test_hanchu_transport_failure_is_retried_then_reported), + ("no_credentials", test_hanchu_run_refuses_to_start_without_credentials), + ("retry_delay_default", test_hanchu_retry_delay_default), + ]: + try: + if fn(): + print(f" FAILED: hanchu_api.{name}") + failed = True + except Exception as e: + print(f" EXCEPTION in hanchu_api.{name}: {e}") + import traceback + + traceback.print_exc() + failed = True + return failed diff --git a/apps/predbat/tests/test_hanchu_cli.py b/apps/predbat/tests/test_hanchu_cli.py new file mode 100644 index 000000000..227e08237 --- /dev/null +++ b/apps/predbat/tests/test_hanchu_cli.py @@ -0,0 +1,352 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test the Hanchu command-line harness helpers +# ----------------------------------------------------------------------------- + +"""Tests for the pure helpers behind `python3 hanchu.py`, the live-hardware test harness. + +The live drivers themselves talk to the real cloud and are excluded from coverage, so everything +that decides WHAT they write, what they restore and how they judge the result lives in these +helpers and is pinned here. A wrong answer from any of them is a wrong write to someone's inverter. +""" + +import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) +import os +import tempfile +from hanchu import ( + HANCHU_CLI_RESTORE_KEYS, + build_cli_schedule, + cli_future_start, + judge_live_window, + load_cli_snapshot, + merge_hanchu_credentials, + restore_payload, + save_cli_snapshot, + settings_mismatches, +) +from hanchu_const import ( + HANCHU_KEY_CHARGE_POWER, + HANCHU_KEY_CHARGE_SOC, + HANCHU_KEY_DISCHARGE_POWER, + HANCHU_KEY_DISCHARGE_SOC, + HANCHU_KEY_GRID_CHARGE_SOC, + HANCHU_KEY_WORK_MODE, + HANCHU_RANGES, +) + +SN = "HC240100001" +SETTINGS = {HANCHU_KEY_DISCHARGE_SOC: "10", HANCHU_KEY_WORK_MODE: "3"} + + +def expect(failed, condition, message): + """Print message when condition is false, returning the accumulated failure flag.""" + if not condition: + print(f"ERROR: {message}") + return True + return failed + + +def test_hanchu_cli_credentials_from_config(): + """apps.yaml fills what the command line left out, the command line wins, and a serial list gives its first entry.""" + failed = False + config = {"hanchu_account": "me@example.com", "hanchu_password": "secret", "hanchu_inverter_sn": ["HC1", "HC2"]} + merged, used = merge_hanchu_credentials({"account": None, "password": None, "serial": None}, config) + failed = expect(failed, merged == {"account": "me@example.com", "password": "secret", "serial": "HC1"}, f"merged {merged}") + failed = expect(failed, used == ["hanchu_account", "hanchu_password", "hanchu_inverter_sn"], f"used {used}") + + merged, used = merge_hanchu_credentials({"account": "other@example.com", "password": None, "serial": "HC9"}, config) + failed = expect(failed, merged["account"] == "other@example.com" and merged["serial"] == "HC9", f"command line should win: {merged}") + failed = expect(failed, used == ["hanchu_password"], f"only the password should come from the file: {used}") + + merged, used = merge_hanchu_credentials({"account": None, "password": None, "serial": None}, {"hanchu_inverter_sn": []}) + failed = expect(failed, merged["serial"] is None and used == [], f"an empty serial list supplies nothing: {merged} {used}") + return failed + + +def test_hanchu_cli_charge_schedule_is_what_predbat_would_send(): + """A live charge test starts now, raises the target 10 points, and holds export at full rate as Predbat does outside an export.""" + failed = False + schedule, notes = build_cli_schedule("charge", 600, 60, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1500) + charge = schedule["charge"] + failed = expect(failed, charge == {"enable": True, "soc": 70, "power": 1500, "start": "10:00:00", "end": "10:10:00"}, f"charge {charge}") + failed = expect(failed, not schedule["export"]["enable"], "export must be disabled") + failed = expect(failed, schedule["export"]["power"] == HANCHU_RANGES[HANCHU_KEY_DISCHARGE_POWER][1], f"export power {schedule['export']['power']}") + failed = expect(failed, schedule["reserve"] == 10, f"reserve should carry the device's current floor: {schedule['reserve']}") + failed = expect(failed, notes == [], f"no notes expected: {notes}") + return failed + + +def test_hanchu_cli_charge_target_respects_the_device_floor(): + """A charge target below the grid-charge limit's 20% floor is raised to it, and the note says so.""" + failed = False + schedule, notes = build_cli_schedule("charge", 600, 5, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1500) + failed = expect(failed, schedule["charge"]["soc"] == 20, f"target {schedule['charge']['soc']}") + failed = expect(failed, any("20" in note for note in notes), f"the raise should be explained: {notes}") + # --target sets the charge target directly, so a tester can watch the battery stop at it. + schedule, _ = build_cli_schedule("charge", 600, 60, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1500, target=62) + failed = expect(failed, schedule["charge"]["soc"] == 62, f"--target charge {schedule['charge']['soc']}") + schedule, _ = build_cli_schedule("export", 600, 40, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1500, target=38) + failed = expect(failed, schedule["export"]["soc"] == 38, f"--target export floor {schedule['export']['soc']}") + return failed + + +def test_hanchu_cli_power_is_capped_at_the_device_range(): + """A requested power above the range is capped, so the harness never asks for more than the device accepts.""" + failed = False + ranges = dict(HANCHU_RANGES) + ranges[HANCHU_KEY_CHARGE_POWER] = (0, 3680) + schedule, notes = build_cli_schedule("charge", 600, 60, SETTINGS, ranges, window_minutes=10, power=9000) + failed = expect(failed, schedule["charge"]["power"] == 3680, f"power {schedule['charge']['power']}") + failed = expect(failed, any("3680" in note for note in notes), f"the cap should be explained: {notes}") + return failed + + +def test_hanchu_cli_export_schedule_floors_inside_the_device_range(): + """An export test drops the floor 10 points but never above the device's 45% ceiling, and stops charging at full rate.""" + failed = False + schedule, _ = build_cli_schedule("export", 600, 80, SETTINGS, HANCHU_RANGES, window_minutes=10, power=2000) + export = schedule["export"] + failed = expect(failed, export["enable"] and export["soc"] == 45 and export["power"] == 2000, f"export {export}") + failed = expect(failed, schedule["charge"]["power"] == HANCHU_RANGES[HANCHU_KEY_CHARGE_POWER][1], f"charge power {schedule['charge']['power']}") + + schedule, _ = build_cli_schedule("export", 600, 30, SETTINGS, HANCHU_RANGES, window_minutes=10, power=2000) + failed = expect(failed, schedule["export"]["soc"] == 20, f"floor should be SoC - 10: {schedule['export']['soc']}") + return failed + + +def test_hanchu_cli_freeze_schedules_use_the_rates_alone(): + """The freezes reproduce execute.py: no window, the rate in the frozen direction at zero, and for freeze charge the reserve raised to SoC + 1. + + Hardware (batpred#5305): a zero discharge rate alone does NOT hold the battery outside a slot - a + HESS-HY-S-6.0K kept feeding a 1 kW house load for ten minutes. Predbat also raises the reserve to + SoC + 1 when it freezes charging on an inverter with no pause mode, so the test must too. + """ + failed = False + schedule, _ = build_cli_schedule("freeze-charge", 600, 20, SETTINGS, HANCHU_RANGES, window_minutes=10, power=None) + failed = expect(failed, not schedule["charge"]["enable"] and not schedule["export"]["enable"], "freeze charge writes no window") + failed = expect(failed, schedule["export"]["power"] == 0 and schedule["charge"]["power"] == HANCHU_RANGES[HANCHU_KEY_CHARGE_POWER][1], f"freeze charge rates {schedule}") + failed = expect(failed, schedule["reserve"] == 21, f"freeze charge should raise the reserve to SoC + 1 = 21, got {schedule['reserve']}") + # Above the floor's 45% ceiling Predbat will not freeze at all (inverter_reserve_max), so neither does the test. + try: + build_cli_schedule("freeze-charge", 600, 60, SETTINGS, HANCHU_RANGES, window_minutes=10, power=None) + failed = expect(failed, False, "freeze charge at 60% should be refused - the reserve cannot go above 45") + except ValueError: + pass + + schedule, _ = build_cli_schedule("freeze-export", 600, 60, SETTINGS, HANCHU_RANGES, window_minutes=10, power=None) + failed = expect(failed, not schedule["charge"]["enable"] and not schedule["export"]["enable"], "freeze export writes no window") + failed = expect(failed, schedule["charge"]["power"] == 0 and schedule["export"]["power"] == HANCHU_RANGES[HANCHU_KEY_DISCHARGE_POWER][1], f"freeze export rates {schedule}") + return failed + + +def test_hanchu_cli_hold_is_a_charge_slot_already_at_its_target(): + """The hold test opens a charge slot now with its target at the current SoC and no charge or discharge power. + + One mechanism for every SoC from the grid-charge limit's 20% floor to 100%, unlike the reserve, + which stops at 45%. It holds only if the inverter idles in a charge slot whose target is met. + """ + failed = False + schedule, _ = build_cli_schedule("hold", 600, 60, SETTINGS, HANCHU_RANGES, window_minutes=10, power=None) + charge = schedule["charge"] + failed = expect(failed, charge["enable"] and charge["soc"] == 60 and charge["power"] == 0 and charge["start"] == "10:00:00", f"hold charge slot {charge}") + failed = expect(failed, not schedule["export"]["enable"] and schedule["export"]["power"] == 0, f"hold export {schedule['export']}") + failed = expect(failed, schedule["reserve"] == 10, f"hold leaves the reserve alone: {schedule['reserve']}") + # Below the grid-charge target's 20% floor the target is 20, said in a note - with no charge power + # the slot still cannot charge, so it remains a test of whether the slot stops discharge. + schedule, notes = build_cli_schedule("hold", 600, 15, SETTINGS, HANCHU_RANGES, window_minutes=10, power=None) + failed = expect(failed, schedule["charge"]["soc"] == 20 and schedule["charge"]["power"] == 0, f"hold at 15% {schedule['charge']}") + failed = expect(failed, any("20" in note for note in notes), f"the raised target should be explained: {notes}") + # Judged exactly like freeze charge. + verdict, _ = judge_live_window("hold", [sample(70, 0, load=800), sample(100, 10, load=800)]) + failed = expect(failed, verdict == "PASS", f"hold verdict {verdict}") + verdict, _ = judge_live_window("hold", [sample(70, 900, load=900), sample(100, 900, load=900)]) + failed = expect(failed, verdict == "FAIL", f"hold drain verdict {verdict}") + return failed + + +def test_hanchu_cli_refuses_a_test_that_could_show_nothing(): + """A full battery cannot show a charge, an empty one cannot show an export, and an unknown scenario is refused.""" + failed = False + for scenario, soc in (("charge", 99), ("export", 6)): + try: + build_cli_schedule(scenario, 600, soc, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1000) + failed = expect(failed, False, f"{scenario} at {soc}% should be refused") + except ValueError: + pass + try: + build_cli_schedule("dance", 600, 50, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1000) + failed = expect(failed, False, "an unknown scenario should be refused") + except ValueError: + pass + # A future write-test window does not need the battery to move, so a full battery is fine. + schedule, _ = build_cli_schedule("charge", 600, 100, SETTINGS, HANCHU_RANGES, window_minutes=10, power=1000, require_effect=False) + failed = expect(failed, schedule["charge"]["soc"] == 100, f"target {schedule['charge']['soc']}") + return failed + + +def test_hanchu_cli_window_never_crosses_midnight(): + """A window running past midnight is cut at 23:59, and one with under two minutes left is refused.""" + failed = False + schedule, notes = build_cli_schedule("charge", 23 * 60 + 52, 60, SETTINGS, HANCHU_RANGES, window_minutes=15, power=1000) + failed = expect(failed, schedule["charge"]["end"] == "23:59:00", f"end {schedule['charge']['end']}") + failed = expect(failed, any("midnight" in note for note in notes), f"the cut should be explained: {notes}") + try: + build_cli_schedule("charge", 23 * 60 + 58, 60, SETTINGS, HANCHU_RANGES, window_minutes=15, power=1000) + failed = expect(failed, False, "a window with one minute left should be refused") + except ValueError: + pass + return failed + + +def test_hanchu_cli_future_start_avoids_now_and_midnight(): + """The write test's window starts hours away from now and always fits before midnight.""" + failed = False + for now in (0, 600, 1200, 1300, 1439): + start = cli_future_start(now, 15) + failed = expect(failed, 0 <= start and start + 15 <= 1439, f"now {now}: window {start}-{start + 15} must fit in the day") + gap = min((start - now) % 1440, (now - (start + 15)) % 1440) + failed = expect(failed, gap >= 60, f"now {now}: window at {start} is within an hour of now") + return failed + + +def test_hanchu_cli_settings_compare_numerically(): + """Read-back values arrive as strings or floats; "3" and 3 match, a real difference or a missing key does not.""" + failed = False + expected = {HANCHU_KEY_WORK_MODE: 3, HANCHU_KEY_CHARGE_SOC: 70, HANCHU_KEY_CHARGE_POWER: 1500} + actual = {HANCHU_KEY_WORK_MODE: "3", HANCHU_KEY_CHARGE_SOC: 70.0, HANCHU_KEY_CHARGE_POWER: "1000"} + mismatches = settings_mismatches(expected, actual) + failed = expect(failed, mismatches == [(HANCHU_KEY_CHARGE_POWER, 1500, "1000")], f"mismatches {mismatches}") + mismatches = settings_mismatches({HANCHU_KEY_CHARGE_SOC: 70}, {}) + failed = expect(failed, mismatches == [(HANCHU_KEY_CHARGE_SOC, 70, None)], f"a missing key is a mismatch: {mismatches}") + return failed + + +def test_hanchu_cli_restore_payload_uses_the_snapshot(): + """Restore writes back exactly the keys asked for, as numbers, and names any the snapshot never held.""" + failed = False + snapshot = {HANCHU_KEY_WORK_MODE: "1", HANCHU_KEY_CHARGE_SOC: "100", HANCHU_KEY_GRID_CHARGE_SOC: "80"} + payload, missing = restore_payload(snapshot, [HANCHU_KEY_WORK_MODE, HANCHU_KEY_CHARGE_SOC, HANCHU_KEY_CHARGE_POWER]) + failed = expect(failed, payload == {HANCHU_KEY_WORK_MODE: 1, HANCHU_KEY_CHARGE_SOC: 100}, f"payload {payload}") + failed = expect(failed, missing == [HANCHU_KEY_CHARGE_POWER], f"missing {missing}") + # The overall charge cap is never written by Predbat, so a full restore leaves it alone too; the + # grid-charge limit carries the charge target, so it is restored. + failed = expect(failed, HANCHU_KEY_CHARGE_SOC not in HANCHU_CLI_RESTORE_KEYS, "the restore set should not include the overall charge cap") + failed = expect(failed, HANCHU_KEY_GRID_CHARGE_SOC in HANCHU_CLI_RESTORE_KEYS, "the restore set should include the grid-charge limit") + return failed + + +def test_hanchu_cli_snapshot_round_trips(): + """A snapshot saved before a write loads back with its serial and settings intact.""" + failed = False + with tempfile.TemporaryDirectory() as folder: + path = save_cli_snapshot(folder, SN, {HANCHU_KEY_WORK_MODE: "3"}) + failed = expect(failed, os.path.exists(path) and SN in os.path.basename(path), f"path {path}") + serial, settings = load_cli_snapshot(path) + failed = expect(failed, serial == SN and settings == {HANCHU_KEY_WORK_MODE: "3"}, f"loaded {serial} {settings}") + return failed + + +def sample(elapsed, battery, load=500, pv=0, ac_pv=0, grid=0, soc=50): + """Build one live-window telemetry sample in Predbat's sign convention.""" + return {"elapsed": elapsed, "battery_power": battery, "load_power": load, "pv_power": pv, "ac_pv_power": ac_pv, "grid_power": grid, "soc": soc} + + +def test_hanchu_cli_judges_a_target_by_whether_the_battery_stops_there(): + """With --target the question is whether the inverter stops at the target by itself, not whether it charged throughout.""" + failed = False + stopped = [sample(70, -3000, soc=20), sample(100, -3000, soc=21), sample(130, -2900, soc=22), sample(160, 0, soc=22), sample(190, 10, soc=22)] + verdict, reason = judge_live_window("charge", stopped, target=22) + failed = expect(failed, verdict == "PASS" and "stopped" in reason, f"stopped at target: {verdict} {reason}") + overran = [sample(70, -3000, soc=20), sample(100, -3000, soc=22), sample(130, -3000, soc=23), sample(160, -3000, soc=24)] + verdict, reason = judge_live_window("charge", overran, target=22) + failed = expect(failed, verdict == "FAIL" and "past" in reason, f"charged past target: {verdict} {reason}") + never = [sample(70, -3000, soc=20), sample(100, -3000, soc=21)] + verdict, reason = judge_live_window("charge", never, target=30) + failed = expect(failed, verdict == "INCONCLUSIVE" and "never reached" in reason, f"target not reached: {verdict} {reason}") + # The export floor is the mirror image: discharging above it, stopped at or below it. + floor = [sample(70, 2000, soc=40), sample(100, 2000, soc=39), sample(130, 0, soc=38), sample(160, 0, soc=38)] + verdict, reason = judge_live_window("export", floor, target=38) + failed = expect(failed, verdict == "PASS", f"stopped at floor: {verdict} {reason}") + return failed + + +def test_hanchu_cli_judges_charge_and_export(): + """Charge passes when the battery charges (negative) after settling, export when it discharges; otherwise they fail.""" + failed = False + verdict, _ = judge_live_window("charge", [sample(10, 0), sample(70, -1400), sample(100, -1500)]) + failed = expect(failed, verdict == "PASS", f"charge verdict {verdict}") + verdict, reason = judge_live_window("charge", [sample(70, 300), sample(100, 280)]) + failed = expect(failed, verdict == "FAIL" and "did not charge" in reason, f"charge fail verdict {verdict}: {reason}") + verdict, _ = judge_live_window("export", [sample(70, 2000), sample(100, 1900)]) + failed = expect(failed, verdict == "PASS", f"export verdict {verdict}") + verdict, _ = judge_live_window("export", [sample(70, -500), sample(100, 0)]) + failed = expect(failed, verdict == "FAIL", f"export fail verdict {verdict}") + # Only readings inside the settling time is not evidence either way. + verdict, _ = judge_live_window("charge", [sample(10, -1500)]) + failed = expect(failed, verdict == "INCONCLUSIVE", f"settling-only verdict {verdict}") + return failed + + +def test_hanchu_cli_judges_freezes_only_when_they_can_show_something(): + """A freeze is only judged while there is something to hold against: house demand for a charge freeze, a solar surplus for an export freeze.""" + failed = False + # Freeze charge: demand exists, battery stays put -> PASS; battery feeds the house -> FAIL. + verdict, _ = judge_live_window("freeze-charge", [sample(70, 0, load=800), sample(100, 20, load=800)]) + failed = expect(failed, verdict == "PASS", f"freeze-charge hold verdict {verdict}") + verdict, _ = judge_live_window("freeze-charge", [sample(70, 750, load=800), sample(100, 760, load=800)]) + failed = expect(failed, verdict == "FAIL", f"freeze-charge drain verdict {verdict}") + # A reserve above the SoC can make an inverter charge from the grid to reach it - not a hold either. + verdict, reason = judge_live_window("freeze-charge", [sample(70, -2000, load=800), sample(100, -2100, load=800)]) + failed = expect(failed, verdict == "FAIL" and "grid" in reason, f"freeze-charge grid-charge verdict {verdict}: {reason}") + # Solar covering the load means a held battery and a free one look the same. + verdict, _ = judge_live_window("freeze-charge", [sample(70, 0, load=300, pv=900), sample(100, 0, load=300, pv=900)]) + failed = expect(failed, verdict == "INCONCLUSIVE", f"freeze-charge no-demand verdict {verdict}") + + # Freeze export: surplus exists and the battery does not charge -> PASS; it soaks up the surplus -> FAIL. + verdict, _ = judge_live_window("freeze-export", [sample(70, 0, load=300, pv=1500), sample(100, 0, load=300, pv=1500)]) + failed = expect(failed, verdict == "PASS", f"freeze-export verdict {verdict}") + # pv_power already carries AC-coupled solar, so the bypass reading is not counted a second time. + verdict, _ = judge_live_window("freeze-export", [sample(70, 0, load=1000, pv=600, ac_pv=600), sample(100, 0, load=1000, pv=600, ac_pv=600)]) + failed = expect(failed, verdict == "INCONCLUSIVE", f"AC solar counted twice: {verdict}") + verdict, _ = judge_live_window("freeze-export", [sample(70, -1100, load=300, pv=1500), sample(100, -1200, load=300, pv=1500)]) + failed = expect(failed, verdict == "FAIL", f"freeze-export charging verdict {verdict}") + verdict, _ = judge_live_window("freeze-export", [sample(70, 300, load=300), sample(100, 300, load=300)]) + failed = expect(failed, verdict == "INCONCLUSIVE", f"freeze-export no-surplus verdict {verdict}") + return failed + + +def run_hanchu_cli_tests(my_predbat): + """Run all Hanchu command-line harness helper tests.""" + failed = False + for name, fn in [ + ("credentials", test_hanchu_cli_credentials_from_config), + ("charge_schedule", test_hanchu_cli_charge_schedule_is_what_predbat_would_send), + ("charge_floor", test_hanchu_cli_charge_target_respects_the_device_floor), + ("power_cap", test_hanchu_cli_power_is_capped_at_the_device_range), + ("export_schedule", test_hanchu_cli_export_schedule_floors_inside_the_device_range), + ("freeze_schedules", test_hanchu_cli_freeze_schedules_use_the_rates_alone), + ("hold_schedule", test_hanchu_cli_hold_is_a_charge_slot_already_at_its_target), + ("refuse_no_effect", test_hanchu_cli_refuses_a_test_that_could_show_nothing), + ("midnight", test_hanchu_cli_window_never_crosses_midnight), + ("future_start", test_hanchu_cli_future_start_avoids_now_and_midnight), + ("settings_compare", test_hanchu_cli_settings_compare_numerically), + ("restore_payload", test_hanchu_cli_restore_payload_uses_the_snapshot), + ("snapshot", test_hanchu_cli_snapshot_round_trips), + ("judge_charge_export", test_hanchu_cli_judges_charge_and_export), + ("judge_freezes", test_hanchu_cli_judges_freezes_only_when_they_can_show_something), + ("judge_target", test_hanchu_cli_judges_a_target_by_whether_the_battery_stops_there), + ]: + try: + if fn(): + print(f" FAILED: hanchu_cli.{name}") + failed = True + except Exception as e: + print(f" EXCEPTION in hanchu_cli.{name}: {e}") + import traceback + + traceback.print_exc() + failed = True + return failed diff --git a/apps/predbat/tests/test_hanchu_config.py b/apps/predbat/tests/test_hanchu_config.py new file mode 100644 index 000000000..ec6258afe --- /dev/null +++ b/apps/predbat/tests/test_hanchu_config.py @@ -0,0 +1,204 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Hanchu config and INVERTER_DEF registration +# ----------------------------------------------------------------------------- + +"""Tests for the Hanchu component registration, INVERTER_DEF entry and APPS_SCHEMA keys.""" + +import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) +from components import COMPONENT_LIST +from config import APPS_SCHEMA, INVERTER_DEF + + +def test_hanchu_component_registered(): + """The component is registered in phase 1 with its event filter and auth gate.""" + failed = False + entry = COMPONENT_LIST.get("hanchu") + if not entry: + print("ERROR: hanchu not in COMPONENT_LIST") + assert False, "test_hanchu_component_registered" + if entry.get("class") != "hanchu.HanchuAPI": + print(f"ERROR: class {entry.get('class')}") + failed = True + if entry.get("event_filter") != "predbat_hanchu_": + print(f"ERROR: event_filter {entry.get('event_filter')}") + failed = True + if not entry.get("inverter"): + print("ERROR: hanchu must be flagged as an inverter component") + failed = True + if entry.get("phase") != 1: + print(f"ERROR: phase {entry.get('phase')}") + failed = True + if not entry.get("can_restart"): + print("ERROR: hanchu should be restartable") + failed = True + # Without required_or the component would start for every Predbat instance, since all + # individual args are optional. + if entry.get("required_or") != ["account"]: + print(f"ERROR: required_or {entry.get('required_or')}") + failed = True + for arg, config_key, default in [ + ("account", "hanchu_account", None), + ("password", "hanchu_password", None), + ("inverter_sn", "hanchu_inverter_sn", None), + ("automatic", "hanchu_automatic", False), + ("automatic_ignore_pv", "hanchu_automatic_ignore_pv", False), + ("control_enable", "hanchu_control_enable", True), + ("work_mode_control", "hanchu_work_mode_control", True), + ("battery_rate_max", "hanchu_battery_rate_max", None), + ("inverter_limit", "hanchu_inverter_limit", None), + ("api_delay", "hanchu_api_delay", 1), + ("min_write_interval", "hanchu_min_write_interval", 60), + ]: + info = entry["args"].get(arg) + if not info: + print(f"ERROR: arg {arg} missing") + failed = True + continue + if info.get("config") != config_key: + print(f"ERROR: arg {arg} config {info.get('config')} != {config_key}") + failed = True + if default is not None and info.get("default") != default: + print(f"ERROR: arg {arg} default {info.get('default')} != {default}") + failed = True + assert not failed, "test_hanchu_component_registered" + + +def test_hanchu_credentials_are_marked_secret(): + """The account e-mail and password are the whole credential, so both must be masked.""" + failed = False + args = COMPONENT_LIST["hanchu"]["args"] + for arg in ("account", "password"): + if not args[arg].get("secret"): + print(f"ERROR: arg {arg} must be marked secret - it is a Hanchu app login credential") + failed = True + assert not failed, "test_hanchu_credentials_are_marked_secret" + + +def test_hanchu_control_enable_defaults_true(): + """An inverter component that does not drive the inverter is not what a user expects.""" + failed = False + if COMPONENT_LIST["hanchu"]["args"]["control_enable"].get("default") is not True: + print("ERROR: control_enable default should be True") + failed = True + # The work-mode escape hatch defaults ON, i.e. Predbat does assert the mode. The deduction is + # that this is needed for the slots to run at all, so defaulting it off would ship a component + # that quietly does nothing on most installs. + if COMPONENT_LIST["hanchu"]["args"]["work_mode_control"].get("default") is not True: + print("ERROR: work_mode_control default should be True") + failed = True + assert not failed, "test_hanchu_control_enable_defaults_true" + + +def test_hanchu_inverter_def_complete(): + """HanchuCloud declares every key the other cloud inverter types declare.""" + failed = False + entry = INVERTER_DEF.get("HanchuCloud") + if not entry: + print("ERROR: HanchuCloud not in INVERTER_DEF") + assert False, "test_hanchu_inverter_def_complete" + # Keys inverter.py reads through .get() with a default are opt-in capabilities a type is meant + # to omit. There is no feed-in-first work mode among the four this API exposes, so HanchuCloud + # must NOT declare support_feedin_first. + optional_keys = {"support_feedin_first"} + reference = set(INVERTER_DEF["SunsynkCloud"].keys()) - optional_keys + missing = reference - set(entry.keys()) + if missing: + print(f"ERROR: HanchuCloud missing keys {sorted(missing)}") + failed = True + if "support_feedin_first" in entry: + print("ERROR: HanchuCloud must not declare support_feedin_first - this API has no feed-in-first mode") + failed = True + expected = { + "name": "HanchuCloud", + "has_rest_api": False, + "has_mqtt_api": False, + # CHG_PWR_LMT / DSCHG_PWR_LMT are real watt setpoints, and zero means freeze. + "output_charge_control": "power", + # Everything goes through the timed slot table; fastChargeDischarge is deliberately unused. + "charge_control_immediate": False, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + "has_target_soc": True, + "has_reserve_soc": True, + # No pause operation exists anywhere in this API. + "has_timed_pause": False, + # Anything else makes inverter.py replace the published select entities with its own + # dummies and the window never reaches the component. + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "%", + "time_button_press": True, + "support_charge_freeze": True, + "support_discharge_freeze": True, + # Wrap-around behaviour is undocumented for the slot pairs, so Predbat splits. + "can_span_midnight": False, + "charge_discharge_with_rate": False, + "target_soc_used_for_discharge": True, + } + for key, value in expected.items(): + if entry.get(key) != value: + print(f"ERROR: HanchuCloud[{key}] = {entry.get(key)} != {value}") + failed = True + assert not failed, "test_hanchu_inverter_def_complete" + + +def test_hanchu_apps_schema_keys(): + """Every hanchu_* key a user may set is declared with the right type.""" + failed = False + expected = { + "hanchu_account": "string", + "hanchu_password": "string", + "hanchu_inverter_sn": "string|string_list", + "hanchu_automatic": "boolean", + "hanchu_automatic_ignore_pv": "boolean", + "hanchu_control_enable": "boolean", + "hanchu_work_mode_control": "boolean", + "hanchu_battery_rate_max": "float", + "hanchu_inverter_limit": "float", + "hanchu_api_delay": "float", + "hanchu_min_write_interval": "integer", + } + for key, kind in expected.items(): + entry = APPS_SCHEMA.get(key) + if not entry: + print(f"ERROR: APPS_SCHEMA missing {key}") + failed = True + continue + if entry.get("type") != kind: + print(f"ERROR: APPS_SCHEMA[{key}] type {entry.get('type')} != {kind}") + failed = True + # Every config key the registry references must exist in the schema, or a user setting it would + # be rejected as unknown by the validator. + for arg, info in COMPONENT_LIST["hanchu"]["args"].items(): + key = info.get("config") + if key and key not in APPS_SCHEMA: + print(f"ERROR: registry arg {arg} points at {key}, which APPS_SCHEMA does not declare") + failed = True + assert not failed, "test_hanchu_apps_schema_keys" + + +def run_hanchu_config_tests(my_predbat): + """Run all Hanchu config/INVERTER_DEF tests.""" + failed = False + for name, fn in [ + ("component_registered", test_hanchu_component_registered), + ("credentials_secret", test_hanchu_credentials_are_marked_secret), + ("control_enable_default", test_hanchu_control_enable_defaults_true), + ("inverter_def", test_hanchu_inverter_def_complete), + ("apps_schema", test_hanchu_apps_schema_keys), + ]: + try: + if fn(): + print(f" FAILED: hanchu_config.{name}") + failed = True + except Exception as e: + print(f" EXCEPTION in hanchu_config.{name}: {e}") + import traceback + + traceback.print_exc() + failed = True + return failed diff --git a/apps/predbat/tests/test_hanchu_const.py b/apps/predbat/tests/test_hanchu_const.py new file mode 100644 index 000000000..d61737a2e --- /dev/null +++ b/apps/predbat/tests/test_hanchu_const.py @@ -0,0 +1,301 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Hanchu ESS cloud constants +# ----------------------------------------------------------------------------- + +"""Tests for the Hanchu constants module (``hanchu_const.py``).""" + +import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) +from hanchu_const import ( + HANCHU_BASE_URL, + HANCHU_CONTROL_KEYS, + HANCHU_ENDPOINTS, + HANCHU_ENERGY, + HANCHU_KEY_CHARGE_END, + HANCHU_KEY_CHARGE_POWER, + HANCHU_KEY_CHARGE_SOC, + HANCHU_KEY_CHARGE_START, + HANCHU_KEY_DISCHARGE_END, + HANCHU_KEY_DISCHARGE_POWER, + HANCHU_KEY_DISCHARGE_SOC, + HANCHU_KEY_DISCHARGE_START, + HANCHU_KEY_WORK_MODE, + HANCHU_RANGES, + HANCHU_SECONDS_PER_DAY, + HANCHU_SLOT_COUNT, + HANCHU_SLOT_DISABLED, + HANCHU_TELEMETRY, + HANCHU_TELEMETRY_NEGATE, + HANCHU_WORK_MODE_FOR_SCHEDULE, + HANCHU_WORK_MODES, + clamp_range, + hhmmss_to_seconds, + scale_to_watts, + seconds_to_hhmmss, + split_window_seconds, + window_is_empty, +) + + +def test_hanchu_base_url_and_endpoints(): + """The base URL and every endpoint path match what the reference integrations post to.""" + failed = False + if HANCHU_BASE_URL != "https://iess3.hanchuess.com": + print(f"ERROR: base URL {HANCHU_BASE_URL}") + failed = True + expected = { + "login": "/gateway/identify/auth/token", + "refresh": "/gateway/identify/auth/token/refresh", + "device_list": "/gateway/app/ha/getDeviceList", + "device_status": "/gateway/app/ha/getDeviceStatus", + "device_statistics": "/gateway/app/ha/getDeviceStatistics", + "menu": "/gateway/app/ha/menu", + "iot_get": "/gateway/app/ha/iotGet", + "iot_set": "/gateway/app/ha/iotSet", + "fast_charge": "/gateway/app/ha/fastChargeDischarge", + } + for key, path in expected.items(): + if HANCHU_ENDPOINTS.get(key) != path: + print(f"ERROR: endpoint {key} = {HANCHU_ENDPOINTS.get(key)} != {path}") + failed = True + extra = set(HANCHU_ENDPOINTS) - set(expected) + if extra: + # An endpoint added without a provenance annotation is exactly the failure mode the + # provenance rules exist to prevent, so a new one has to be added here deliberately. + print(f"ERROR: undocumented extra endpoints {sorted(extra)}") + failed = True + assert not failed, "test_hanchu_base_url_and_endpoints" + + +def test_hanchu_control_keys_cover_every_setting_predbat_writes(): + """The one batched iotGet asks for every key the write path can send, and nothing is missing.""" + failed = False + for key in (HANCHU_KEY_WORK_MODE, HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER, HANCHU_KEY_CHARGE_SOC, HANCHU_KEY_DISCHARGE_SOC): + if key not in HANCHU_CONTROL_KEYS: + print(f"ERROR: control key {key} missing from the read set") + failed = True + # Three charge slots and three discharge slots, each with a start and an end: twelve keys. + for template in (HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END): + for index in range(1, HANCHU_SLOT_COUNT + 1): + if template.format(index) not in HANCHU_CONTROL_KEYS: + print(f"ERROR: slot key {template.format(index)} missing from the read set") + failed = True + if len(HANCHU_CONTROL_KEYS) != len(set(HANCHU_CONTROL_KEYS)): + print("ERROR: the read set contains duplicates") + failed = True + # Six scalars plus twelve slot keys. A count check as well as the membership checks, so an + # accidental extra key cannot slip in unnoticed. + if len(HANCHU_CONTROL_KEYS) != 18: + print(f"ERROR: expected 18 control keys, got {len(HANCHU_CONTROL_KEYS)}") + failed = True + assert not failed, "test_hanchu_control_keys_cover_every_setting_predbat_writes" + + +def test_hanchu_ranges_encode_the_narrow_soc_limits(): + """The two SoC limits are narrower than Predbat's own 0-100, which the component must clamp to.""" + failed = False + if HANCHU_RANGES.get(HANCHU_KEY_CHARGE_SOC) != (50, 100): + print(f"ERROR: charge SoC range {HANCHU_RANGES.get(HANCHU_KEY_CHARGE_SOC)} != (50, 100)") + failed = True + if HANCHU_RANGES.get(HANCHU_KEY_DISCHARGE_SOC) != (5, 45): + print(f"ERROR: discharge SoC range {HANCHU_RANGES.get(HANCHU_KEY_DISCHARGE_SOC)} != (5, 45)") + failed = True + # Zero must be INSIDE both power ranges: zero is how Predbat expresses freeze, so a range + # starting at 1 would silently turn every freeze into a 1 W trickle. + for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER): + low, _high = HANCHU_RANGES.get(key, (None, None)) + if low != 0: + print(f"ERROR: {key} range starts at {low}, but 0 must be accepted so a freeze survives the clamp") + failed = True + assert not failed, "test_hanchu_ranges_encode_the_narrow_soc_limits" + + +def test_clamp_range_moves_only_out_of_range_values(): + """clamp_range reports whether it had to move a value, so the caller can log it once.""" + failed = False + cases = [ + (HANCHU_KEY_CHARGE_SOC, 80, 80, False), + (HANCHU_KEY_CHARGE_SOC, 30, 50, True), + (HANCHU_KEY_CHARGE_SOC, 120, 100, True), + (HANCHU_KEY_DISCHARGE_SOC, 10, 10, False), + (HANCHU_KEY_DISCHARGE_SOC, 60, 45, True), + (HANCHU_KEY_DISCHARGE_SOC, 1, 5, True), + # A freeze - rate zero - must pass through untouched, not be clamped up. + (HANCHU_KEY_CHARGE_POWER, 0, 0, False), + (HANCHU_KEY_CHARGE_POWER, 9000, 5000, True), + # An unknown key has no range and is returned as-is. + ("SOMETHING_ELSE", 1234, 1234, False), + ] + for key, value, expect_value, expect_clamped in cases: + got_value, got_clamped = clamp_range(key, value) + if got_value != expect_value or got_clamped != expect_clamped: + print(f"ERROR: clamp_range({key}, {value}) = {(got_value, got_clamped)} != {(expect_value, expect_clamped)}") + failed = True + # Junk coerces to 0 and is then clamped into range like any other number, rather than raising + # inside the write path. + got_value, got_clamped = clamp_range(HANCHU_KEY_CHARGE_SOC, "unavailable") + if got_value != 50 or not got_clamped: + print(f"ERROR: clamp_range of junk = {(got_value, got_clamped)} != (50, True)") + failed = True + # Per-device ranges from the menu endpoint override the built-in defaults. + got_value, got_clamped = clamp_range(HANCHU_KEY_CHARGE_POWER, 6000, {HANCHU_KEY_CHARGE_POWER: (0, 7000)}) + if got_value != 6000 or got_clamped: + print(f"ERROR: menu-supplied range not honoured: {(got_value, got_clamped)}") + failed = True + assert not failed, "test_clamp_range_moves_only_out_of_range_values" + + +def test_time_encoding_round_trips(): + """Slots carry seconds since midnight; the two converters must agree.""" + failed = False + for text, seconds in (("00:00:00", 0), ("01:30:00", 5400), ("23:45:00", 85500), ("02:15:00", 8100)): + if hhmmss_to_seconds(text) != seconds: + print(f"ERROR: hhmmss_to_seconds({text}) = {hhmmss_to_seconds(text)} != {seconds}") + failed = True + if seconds_to_hhmmss(seconds) != text: + print(f"ERROR: seconds_to_hhmmss({seconds}) = {seconds_to_hhmmss(seconds)} != {text}") + failed = True + # Predbat spells midnight as 24:00:00. That must NOT become 0, because 0 is how a DISABLED + # slot is written and a full-day window would collapse into nothing. + if hhmmss_to_seconds("24:00:00") != HANCHU_SECONDS_PER_DAY - 60: + print(f"ERROR: 24:00:00 = {hhmmss_to_seconds('24:00:00')}, should clamp to one minute before midnight") + failed = True + for junk in ("", None, "unavailable", "not:a:time"): + if hhmmss_to_seconds(junk) != 0: + print(f"ERROR: hhmmss_to_seconds({junk!r}) = {hhmmss_to_seconds(junk)} != 0") + failed = True + if seconds_to_hhmmss(junk) != "00:00:00": + print(f"ERROR: seconds_to_hhmmss({junk!r}) = {seconds_to_hhmmss(junk)} != 00:00:00") + failed = True + assert not failed, "test_time_encoding_round_trips" + + +def test_split_window_seconds_splits_at_midnight(): + """A window running past midnight fills slot 1 and puts the remainder in slot 2.""" + failed = False + first, second = split_window_seconds("01:00:00", "05:00:00") + if first != (3600, 18000) or second != (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED): + print(f"ERROR: plain window split to {first} {second}") + failed = True + # 23:00 to 02:00 next day, which Predbat spells as an end of 26:00. + first, second = split_window_seconds("23:00:00", "26:00:00") + if first != (82800, HANCHU_SECONDS_PER_DAY - 60) or second != (HANCHU_SLOT_DISABLED, 7200): + print(f"ERROR: midnight-spanning window split to {first} {second}") + failed = True + # Exactly midnight is not a wrap: it ends at the end of the day and slot 2 stays disabled. + first, second = split_window_seconds("22:00:00", "24:00:00") + if first != (79200, HANCHU_SECONDS_PER_DAY) or second != (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED): + print(f"ERROR: midnight-ending window split to {first} {second}") + failed = True + assert not failed, "test_split_window_seconds_splits_at_midnight" + + +def test_window_is_empty_detects_disabled_and_inverted(): + """start == end is the disabled form, and an inverted window counts as empty too.""" + failed = False + for start, end, expect in ((0, 0, True), (3600, 3600, True), (18000, 3600, True), (3600, 18000, False)): + got = window_is_empty(start, end) + if got != expect: + print(f"ERROR: window_is_empty({start}, {end}) = {got} != {expect}") + failed = True + assert not failed, "test_window_is_empty_detects_disabled_and_inverted" + + +def test_scale_to_watts_trusts_the_unit_field_over_the_heuristic(): + """The sibling unit string wins; the magnitude heuristic is only the fallback.""" + failed = False + cases = [ + # A unit string is trusted even when it contradicts the heuristic: 2.5 kW is 2500 W, and + # 4 W stays 4 W despite being below the heuristic's threshold. + (2.5, "kW", 2500.0), + (2.5, "KW", 2500.0), + (4.0, "W", 4.0), + (1500, "W", 1500.0), + # No unit: below the threshold is assumed to be kW, at or above it is assumed to be W. + (2.5, None, 2500.0), + (1500, None, 1500.0), + (-3.0, None, -3000.0), + # An unrecognised unit falls back to the heuristic rather than guessing a multiplier. + (2.5, "kilowatt", 2500.0), + ] + for value, unit, expect in cases: + got = scale_to_watts(value, unit) + if got != expect: + print(f"ERROR: scale_to_watts({value}, {unit}) = {got} != {expect}") + failed = True + for junk in (None, "unavailable", ""): + if scale_to_watts(junk) is not None: + print(f"ERROR: scale_to_watts({junk!r}) should be None, got {scale_to_watts(junk)}") + failed = True + assert not failed, "test_scale_to_watts_trusts_the_unit_field_over_the_heuristic" + + +def test_field_maps_cover_the_predbat_args(): + """Telemetry and energy maps name every arg automatic_config binds, with the right sign flips.""" + failed = False + for leaf in ("soc", "battery_power", "grid_power", "pv_power", "load_power"): + if leaf not in HANCHU_TELEMETRY: + print(f"ERROR: telemetry leaf {leaf} missing") + failed = True + for leaf in ("load_today", "import_today", "export_today", "pv_today"): + if leaf not in HANCHU_ENERGY: + print(f"ERROR: energy leaf {leaf} missing") + failed = True + # Hanchu reports battery positive on CHARGE and grid positive on IMPORT; Predbat's convention + # is the opposite for both, so both must be negated and nothing else may be. + if set(HANCHU_TELEMETRY_NEGATE) != {"battery_power", "grid_power"}: + print(f"ERROR: negate set {HANCHU_TELEMETRY_NEGATE} != battery_power, grid_power") + failed = True + for leaf in HANCHU_TELEMETRY_NEGATE: + if leaf not in HANCHU_TELEMETRY: + print(f"ERROR: negated leaf {leaf} is not a telemetry leaf at all") + failed = True + assert not failed, "test_field_maps_cover_the_predbat_args" + + +def test_work_mode_for_schedule_is_user_defined(): + """The inferred schedule work mode is User-defined, and is named in the table. + + This is the single least well evidenced value in the component - nothing states which mode + honours the timed slots. The test pins the deduction so that changing it is a deliberate act + with a visible diff, not a quiet edit. + """ + failed = False + if HANCHU_WORK_MODES.get(HANCHU_WORK_MODE_FOR_SCHEDULE) != "User-defined": + print(f"ERROR: schedule work mode {HANCHU_WORK_MODE_FOR_SCHEDULE} is {HANCHU_WORK_MODES.get(HANCHU_WORK_MODE_FOR_SCHEDULE)}, not User-defined") + failed = True + if sorted(HANCHU_WORK_MODES) != [1, 2, 3, 4]: + print(f"ERROR: work mode values {sorted(HANCHU_WORK_MODES)} != [1, 2, 3, 4]") + failed = True + assert not failed, "test_work_mode_for_schedule_is_user_defined" + + +def run_hanchu_const_tests(my_predbat): + """Run all Hanchu constants tests.""" + failed = False + for name, fn in [ + ("base_url_and_endpoints", test_hanchu_base_url_and_endpoints), + ("control_keys", test_hanchu_control_keys_cover_every_setting_predbat_writes), + ("ranges", test_hanchu_ranges_encode_the_narrow_soc_limits), + ("clamp_range", test_clamp_range_moves_only_out_of_range_values), + ("time_encoding", test_time_encoding_round_trips), + ("split_window", test_split_window_seconds_splits_at_midnight), + ("window_is_empty", test_window_is_empty_detects_disabled_and_inverted), + ("scale_to_watts", test_scale_to_watts_trusts_the_unit_field_over_the_heuristic), + ("field_maps", test_field_maps_cover_the_predbat_args), + ("work_mode", test_work_mode_for_schedule_is_user_defined), + ]: + try: + if fn(): + print(f" FAILED: hanchu_const.{name}") + failed = True + except Exception as e: + print(f" EXCEPTION in hanchu_const.{name}: {e}") + import traceback + + traceback.print_exc() + failed = True + return failed diff --git a/apps/predbat/tests/test_hanchu_control.py b/apps/predbat/tests/test_hanchu_control.py new file mode 100644 index 000000000..d4143de78 --- /dev/null +++ b/apps/predbat/tests/test_hanchu_control.py @@ -0,0 +1,952 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Hanchu ESS cloud control logic +# ----------------------------------------------------------------------------- + +"""Tests for the Hanchu component's control path: clamping, slot building and batched writes.""" + +import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) +import asyncio +import time +from hanchu_const import ( + HANCHU_KEY_CHARGE_END, + HANCHU_KEY_CHARGE_POWER, + HANCHU_KEY_CHARGE_SOC, + HANCHU_KEY_CHARGE_START, + HANCHU_KEY_DISCHARGE_END, + HANCHU_KEY_DISCHARGE_POWER, + HANCHU_KEY_DISCHARGE_SOC, + HANCHU_KEY_DISCHARGE_START, + HANCHU_KEY_GRID_CHARGE_SOC, + HANCHU_KEY_WORK_MODE, + HANCHU_SECONDS_PER_DAY, + HANCHU_SLOT_DISABLED, + HANCHU_WORK_MODE_FOR_SCHEDULE, +) +from tests.test_hanchu_api import MockHanchu, envelope, logged, patched_session +from tests.test_infra import run_async as run_async_local + +SN = "HC240100001" + + +def ready(client=None, **kwargs): + """Return a MockHanchu already past discovery, with a live token, ready to write.""" + client = client or MockHanchu(**kwargs) + client._token = "tok" + client._token_time = time.time() + client.device_list = [SN] + return client + + +def schedule(charge=None, export=None, reserve=10): + """Build a schedule dict in the shape get_schedule_settings_ha() produces. + + Both rates default to 5000 W, as Predbat leaves them outside a freeze: a zero rate is how Predbat + asks this inverter to hold (discharge 0) or to freeze export (charge 0), so a test sets one only + when it means to. + """ + base = { + "reserve": reserve, + "charge": {"enable": False, "soc": 0, "power": 5000, "start": "00:00:00", "end": "00:00:00"}, + "export": {"enable": False, "soc": 0, "power": 5000, "start": "00:00:00", "end": "00:00:00"}, + } + if charge: + base["charge"].update(charge) + if export: + base["export"].update(export) + return base + + +def test_hanchu_charge_window_fills_slot_one(): + """A plain charge window lands in slot 1 with the target, the rate and the work mode.""" + failed = False + client = ready() + payload = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:30:00", "end": "04:30:00"})) + expected = { + HANCHU_KEY_CHARGE_START.format(1): 5400, + HANCHU_KEY_CHARGE_END.format(1): 16200, + HANCHU_KEY_GRID_CHARGE_SOC: 90, + HANCHU_KEY_CHARGE_POWER: 3000, + HANCHU_KEY_WORK_MODE: HANCHU_WORK_MODE_FOR_SCHEDULE, + } + for key, value in expected.items(): + if payload.get(key) != value: + print(f"ERROR: payload[{key}] = {payload.get(key)} != {value}") + failed = True + # The charge target is the grid-to-battery limit. CHG_BAT_SOC_LMT is the device's overall maximum + # charge SoC, which would cap solar charging as well, so it is never written. + if HANCHU_KEY_CHARGE_SOC in payload: + print(f"ERROR: {HANCHU_KEY_CHARGE_SOC} = {payload[HANCHU_KEY_CHARGE_SOC]} was written; it is the user's overall cap") + failed = True + # The discharge floor carries the reserve, because no export window is running. + if payload.get(HANCHU_KEY_DISCHARGE_SOC) != 10: + print(f"ERROR: discharge floor {payload.get(HANCHU_KEY_DISCHARGE_SOC)} should be the reserve, 10") + failed = True + # Slots 2 and 3 are written as disabled: User-defined mode runs all three, so a slot left over + # from the Hanchu app must not keep running against the plan. + for index in (2, 3): + for key in (HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END): + if payload.get(key.format(index)) != HANCHU_SLOT_DISABLED: + print(f"ERROR: {key.format(index)} = {payload.get(key.format(index))}, expected disabled") + failed = True + assert not failed, "test_hanchu_charge_window_fills_slot_one" + + +def test_hanchu_midnight_spanning_window_uses_slot_two(): + """Predbat spells 'until 02:00 tomorrow' as an end of 26:00; slot 2 carries the remainder.""" + failed = False + client = ready() + payload = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 80, "power": 2000, "start": "23:00:00", "end": "26:00:00"})) + if payload.get(HANCHU_KEY_CHARGE_START.format(1)) != 82800 or payload.get(HANCHU_KEY_CHARGE_END.format(1)) != HANCHU_SECONDS_PER_DAY - 60: + print(f"ERROR: slot 1 = {payload.get(HANCHU_KEY_CHARGE_START.format(1))}-{payload.get(HANCHU_KEY_CHARGE_END.format(1))}") + failed = True + if payload.get(HANCHU_KEY_CHARGE_START.format(2)) != HANCHU_SLOT_DISABLED or payload.get(HANCHU_KEY_CHARGE_END.format(2)) != 7200: + print(f"ERROR: slot 2 = {payload.get(HANCHU_KEY_CHARGE_START.format(2))}-{payload.get(HANCHU_KEY_CHARGE_END.format(2))}") + failed = True + assert not failed, "test_hanchu_midnight_spanning_window_uses_slot_two" + + +def test_hanchu_soc_limits_are_clamped_and_explained_once(): + """The narrow SoC ranges are a real limitation, so the clamp is logged - but only once.""" + failed = False + client = ready() + # A 10% charge target is below the grid-charge limit's floor of 20, and a 70% export target is + # above the discharge floor's ceiling of 45. Neither can be expressed on this hardware. + client.set_mock_clock(13 * 60) + plan = schedule(charge={"enable": True, "soc": 10, "power": 2000, "start": "01:00:00", "end": "04:00:00"}, export={"enable": True, "soc": 70, "power": 3000, "start": "12:00:00", "end": "16:00:00"}) + payload = client.build_write_payload(SN, plan) + if payload.get(HANCHU_KEY_GRID_CHARGE_SOC) != 20: + print(f"ERROR: charge target {payload.get(HANCHU_KEY_GRID_CHARGE_SOC)} should be clamped up to 20") + failed = True + if payload.get(HANCHU_KEY_DISCHARGE_SOC) != 45: + print(f"ERROR: export target {payload.get(HANCHU_KEY_DISCHARGE_SOC)} should be clamped down to 45") + failed = True + if not logged(client, "only accepts 20-100"): + print("ERROR: the charge target clamp was not explained") + failed = True + if not logged(client, "only accepts 5-45"): + print("ERROR: the export target clamp was not explained") + failed = True + # Rebuilt on the next cycle with the same out-of-range plan: the clamp still applies, but the + # explanation must not repeat, or hours of ordinary planning would bury every other log line. + before = len(client.log_messages) + payload = client.build_write_payload(SN, plan) + new_clamp_lines = [message for message in client.log_messages[before:] if "only accepts" in message] + if new_clamp_lines: + print(f"ERROR: the clamp was re-explained on a later cycle: {new_clamp_lines}") + failed = True + if payload.get(HANCHU_KEY_GRID_CHARGE_SOC) != 20: + print("ERROR: the clamp itself must keep applying even once it has been explained") + failed = True + assert not failed, "test_hanchu_soc_limits_are_clamped_and_explained_once" + + +def test_hanchu_freeze_sends_a_zero_rate_unclamped(): + """Zero is how Predbat expresses freeze, and it must survive the clamp as zero.""" + failed = False + client = ready() + payload = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 0, "start": "01:00:00", "end": "04:00:00"})) + if payload.get(HANCHU_KEY_CHARGE_POWER) != 0: + print(f"ERROR: freeze rate became {payload.get(HANCHU_KEY_CHARGE_POWER)}, not 0") + failed = True + if logged(client, "only accepts"): + print("ERROR: a zero rate must not be reported as clamped") + failed = True + assert not failed, "test_hanchu_freeze_sends_a_zero_rate_unclamped" + + +def test_hanchu_discharge_floor_tracks_the_active_export_window(): + """The floor is device-wide, so it carries the export target only while that window is running.""" + failed = False + client = ready() + plan = schedule(export={"enable": True, "soc": 20, "power": 3000, "start": "12:00:00", "end": "16:00:00"}, reserve=8) + + # Inside the window: the floor is the export target. + client.set_mock_clock(13 * 60) + payload = client.build_write_payload(SN, plan) + if payload.get(HANCHU_KEY_DISCHARGE_SOC) != 20: + print(f"ERROR: inside the window the floor is {payload.get(HANCHU_KEY_DISCHARGE_SOC)}, should be the export target 20") + failed = True + + # Outside it: the floor is the reserve, so the battery is not drained after the window ends. + client.set_mock_clock(18 * 60) + payload = client.build_write_payload(SN, plan) + if payload.get(HANCHU_KEY_DISCHARGE_SOC) != 8: + print(f"ERROR: outside the window the floor is {payload.get(HANCHU_KEY_DISCHARGE_SOC)}, should be the reserve 8") + failed = True + + # A DISABLED export window never claims the floor, whatever the clock says. + client.set_mock_clock(13 * 60) + payload = client.build_write_payload(SN, schedule(export={"enable": False, "soc": 20, "start": "12:00:00", "end": "16:00:00"}, reserve=8)) + if payload.get(HANCHU_KEY_DISCHARGE_SOC) != 8: + print(f"ERROR: a disabled window claimed the floor: {payload.get(HANCHU_KEY_DISCHARGE_SOC)}") + failed = True + assert not failed, "test_hanchu_discharge_floor_tracks_the_active_export_window" + + +def test_hanchu_every_unused_slot_is_disabled(): + """Predbat owns all six slots: a superseded window is retracted and unused slots are disabled. + + User-defined mode runs all three charge and all three discharge slots, and 00:00-00:00 is how a + slot is switched off (confirmed on hardware, batpred#5305). A slot left over from the Hanchu app + would otherwise keep running against the plan. + """ + failed = False + client = ready() + first = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "01:00:00", "end": "04:00:00"})) + client.applied_payload[SN] = dict(first) + + # Predbat no longer wants a window: every slot, including the one we filled, is disabled. + second = client.build_write_payload(SN, schedule()) + for index in (1, 2, 3): + for key in (HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END): + if second.get(key.format(index)) != HANCHU_SLOT_DISABLED: + print(f"ERROR: {key.format(index)} = {second.get(key.format(index))}, expected disabled") + failed = True + # A cold start with no plan still disables every slot, so an app slot cannot run. + cold = MockHanchu() + empty = cold.build_write_payload(SN, schedule()) + slot_keys = sorted(key for key in empty if key.startswith(("TCT_", "TDT_"))) + if len(slot_keys) != 12 or any(empty[key] != HANCHU_SLOT_DISABLED for key in slot_keys): + print(f"ERROR: a cold start should disable all twelve slot keys, got {slot_keys}") + failed = True + assert not failed, "test_hanchu_every_unused_slot_is_disabled" + + +def test_hanchu_concurrent_writes_are_serialised(): + """Two writes started together never overlap on the wire: the cloud refuses concurrent control + calls with error 300004 (batpred#5305).""" + failed = False + client = ready() + client.min_write_interval = 0 + in_flight = {"now": 0, "max": 0, "calls": 0} + + async def slow_post(endpoint_key, body=None, anonymous=False): + in_flight["now"] += 1 + in_flight["calls"] += 1 + in_flight["max"] = max(in_flight["max"], in_flight["now"]) + await asyncio.sleep(0.05) + in_flight["now"] -= 1 + return True, {} + + client._post = slow_post + one = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "01:00:00", "end": "04:00:00"})) + two = client.build_write_payload(SN, schedule(export={"enable": True, "soc": 20, "power": 2000, "start": "17:00:00", "end": "19:00:00"})) + + async def both(): + return await asyncio.gather(client._write_payload(SN, one), client._write_payload(SN, two)) + + results = run_async_local(both()) + if in_flight["max"] != 1: + print(f"ERROR: {in_flight['max']} writes were in flight at once") + failed = True + if in_flight["calls"] != 2 or results != [True, True]: + print(f"ERROR: expected two sequential accepted writes, got calls={in_flight['calls']} results={results}") + failed = True + assert not failed, "test_hanchu_concurrent_writes_are_serialised" + + +def test_hanchu_work_mode_is_asserted_only_with_a_window_and_only_when_enabled(): + """The work mode is the least well evidenced write, so it is never sent speculatively.""" + failed = False + client = ready() + # No window at all: the mode is the user's business. + if HANCHU_KEY_WORK_MODE in client.build_write_payload(SN, schedule()): + print("ERROR: the work mode was written with no window enabled") + failed = True + # An export-only window still needs the timed mode. + payload = client.build_write_payload(SN, schedule(export={"enable": True, "soc": 20, "power": 3000, "start": "12:00:00", "end": "16:00:00"})) + if payload.get(HANCHU_KEY_WORK_MODE) != HANCHU_WORK_MODE_FOR_SCHEDULE: + print(f"ERROR: an export window did not assert the work mode: {payload.get(HANCHU_KEY_WORK_MODE)}") + failed = True + # The escape hatch: with work_mode_control off, the windows still go out and the mode never does. + opted_out = ready(client=MockHanchu(work_mode_control=False)) + payload = opted_out.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "01:00:00", "end": "04:00:00"})) + if HANCHU_KEY_WORK_MODE in payload: + print("ERROR: hanchu_work_mode_control: False still wrote the work mode") + failed = True + if payload.get(HANCHU_KEY_CHARGE_START.format(1)) != 3600: + print("ERROR: opting out of work mode control must not stop the window being written") + failed = True + assert not failed, "test_hanchu_work_mode_is_asserted_only_with_a_window_and_only_when_enabled" + + +def test_hanchu_write_is_one_call_carrying_every_setting(): + """The whole control state goes out in ONE iotSet - the batching the API needs.""" + failed = False + client = ready() + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if not ok: + print("ERROR: the write reported failure") + failed = True + if session_cls.return_value.post.call_count != 1: + print(f"ERROR: the write took {session_cls.return_value.post.call_count} calls, must be exactly 1") + failed = True + body = session_cls.return_value.post.call_args.kwargs["json"] + if body.get("sn") != SN or body.get("devType") != "2": + print(f"ERROR: iotSet body {body}") + failed = True + value = body.get("value") or {} + for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER, HANCHU_KEY_GRID_CHARGE_SOC, HANCHU_KEY_DISCHARGE_SOC, HANCHU_KEY_CHARGE_START.format(1), HANCHU_KEY_WORK_MODE): + if key not in value: + print(f"ERROR: the batched value dict is missing {key}") + failed = True + if client.applied_payload.get(SN) != value: + print("ERROR: an accepted payload was not cached as applied") + failed = True + assert not failed, "test_hanchu_write_is_one_call_carrying_every_setting" + + +def test_hanchu_unchanged_payload_sends_nothing(): + """Predbat presses the write button every cycle, so change detection is what stops a write storm.""" + failed = False + client = ready() + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + with patched_session([(200, envelope(data={}))]) as session_cls: + run_async_local(client.apply_schedule(SN)) + first_calls = session_cls.return_value.post.call_count + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + second_calls = session_cls.return_value.post.call_count + if first_calls != 1: + print(f"ERROR: the first write took {first_calls} calls") + failed = True + if second_calls != 0: + print(f"ERROR: an unchanged payload sent {second_calls} call(s)") + failed = True + if not ok: + print("ERROR: 'already matches' must report True - the device does match the plan") + failed = True + if not logged(client, "unchanged"): + print("ERROR: the skipped write was not explained") + failed = True + assert not failed, "test_hanchu_unchanged_payload_sends_nothing" + + +def test_hanchu_write_button_does_not_force(): + """The button is Predbat's ordinary per-cycle apply, so forcing it would write every cycle.""" + failed = False + client = ready() + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + entity = client._control_name("switch", SN, "battery_schedule_charge_write") + with patched_session([(200, envelope(data={}))]) as session_cls: + run_async_local(client.switch_event(entity, "turn_on")) + first_calls = session_cls.return_value.post.call_count + with patched_session([(200, envelope(data={}))]) as session_cls: + run_async_local(client.switch_event(entity, "turn_on")) + second_calls = session_cls.return_value.post.call_count + if first_calls != 1 or second_calls != 0: + print(f"ERROR: repeated button presses produced {first_calls} then {second_calls} writes; the second must be 0") + failed = True + # The press is what marks the device as Predbat-driven, so the reconcile loop may act from here. + if SN not in client.control_active: + print("ERROR: the button press did not mark the device as actively controlled") + failed = True + assert not failed, "test_hanchu_write_button_does_not_force" + + +def test_hanchu_read_only_blocks_the_reconcile_write(): + """The payload is time-aware, so a window transition would write in read-only mode without this.""" + failed = False + client = ready() + client.control_active.add(SN) + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + client.state["switch.predbat_set_read_only"] = "on" + with patched_session([(200, envelope(data={}))]) as session_cls: + run_async_local(client._reconcile_control(SN)) + if session_cls.return_value.post.call_count: + print(f"ERROR: {session_cls.return_value.post.call_count} write(s) went out in read-only mode") + failed = True + # And with read-only off it does write, so the test above is not passing for the wrong reason. + client.state["switch.predbat_set_read_only"] = "off" + with patched_session([(200, envelope(data={}))]) as session_cls: + run_async_local(client._reconcile_control(SN)) + if session_cls.return_value.post.call_count != 1: + print(f"ERROR: with read-only off the reconcile should write once, got {session_cls.return_value.post.call_count}") + failed = True + assert not failed, "test_hanchu_read_only_blocks_the_reconcile_write" + + +def test_hanchu_reconcile_ignores_a_device_predbat_does_not_drive(): + """A startup cycle must never clobber an inverter's slots before there is a plan to apply.""" + failed = False + client = ready() + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + with patched_session([(200, envelope(data={}))]) as session_cls: + run_async_local(client._reconcile_control(SN)) + if session_cls.return_value.post.call_count: + print(f"ERROR: {session_cls.return_value.post.call_count} write(s) went to a device whose write button has never been pressed") + failed = True + assert not failed, "test_hanchu_reconcile_ignores_a_device_predbat_does_not_drive" + + +def test_hanchu_control_disabled_writes_nothing(): + """hanchu_control_enable: False is monitoring only.""" + failed = False + client = ready(client=MockHanchu(control_enable=False)) + client.control_active.add(SN) + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if ok or session_cls.return_value.post.call_count: + print(f"ERROR: control disabled still wrote: ok={ok} calls={session_cls.return_value.post.call_count}") + failed = True + assert not failed, "test_hanchu_control_disabled_writes_nothing" + + +def test_hanchu_pacing_holds_a_change_rather_than_dropping_it(): + """A held change is retried on the next eligible cycle, so the applied cache must not advance.""" + failed = False + client = ready(client=MockHanchu(min_write_interval=300)) + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + with patched_session([(200, envelope(data={}))]): + run_async_local(client.apply_schedule(SN)) + applied_after_first = dict(client.applied_payload.get(SN) or {}) + + # A new plan moments later is inside the pacing window. + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 80, "power": 2000, "start": "02:00:00", "end": "05:00:00"}) + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if ok: + print("ERROR: a held change must not report the device as matching the plan") + failed = True + if session_cls.return_value.post.call_count: + print(f"ERROR: {session_cls.return_value.post.call_count} write(s) escaped the pacing interval") + failed = True + if client.applied_payload.get(SN) != applied_after_first: + print("ERROR: the applied cache advanced for a change that was never sent, so it would never be retried") + failed = True + if not logged(client, "hanchu_min_write_interval"): + print("ERROR: the held change was not explained, and the config key was not named") + failed = True + + # Once the interval has elapsed the held change goes out. + client.last_write_time[SN] = time.time() - 301 + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if not ok or session_cls.return_value.post.call_count != 1: + print(f"ERROR: the held change was not retried: ok={ok} calls={session_cls.return_value.post.call_count}") + failed = True + assert not failed, "test_hanchu_pacing_holds_a_change_rather_than_dropping_it" + + +def test_hanchu_rejected_write_is_retried_once_then_paced(): + """A rejected batch strands every setting in it, so one retry - then pacing, not hammering.""" + failed = False + client = ready() + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + responses = [(200, envelope(success=False, code=100, msg="device busy")), (200, envelope(success=False, code=100, msg="device busy"))] + with patched_session(responses) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if ok: + print("ERROR: a rejected write must not report success") + failed = True + if session_cls.return_value.post.call_count != 2: + print(f"ERROR: expected the original write plus one retry, got {session_cls.return_value.post.call_count}") + failed = True + if client.applied_payload.get(SN): + print("ERROR: a rejected payload must not be cached as applied, or it would never be resent") + failed = True + # The attempt is still stamped, so the reconcile loop cannot re-post it on every tick forever. + if SN not in client.last_write_time: + print("ERROR: a failed write did not stamp the pacing timestamp, so a retry storm is possible") + failed = True + assert not failed, "test_hanchu_rejected_write_is_retried_once_then_paced" + + +def test_hanchu_partial_accept_is_not_treated_as_applied(): + """A per-key rejection map means the write only half landed, so it must be rebuilt and resent.""" + failed = False + client = ready() + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + ack = {"responseSemMap": {HANCHU_KEY_CHARGE_POWER: "1", HANCHU_KEY_CHARGE_SOC: "0"}} + with patched_session([(200, envelope(data=ack))]): + ok = run_async_local(client.apply_schedule(SN)) + if ok: + print("ERROR: a partial accept must not report success") + failed = True + if client.applied_payload.get(SN): + print("ERROR: a partially accepted payload must not be cached as applied") + failed = True + if not logged(client, HANCHU_KEY_CHARGE_SOC): + print("ERROR: the refused key was not named in the log") + failed = True + # The map is single-source evidence, so its ABSENCE must never be read as a failure. + client.applied_payload.pop(SN, None) + with patched_session([(200, envelope(data={}))]): + ok = run_async_local(client.apply_schedule(SN)) + if not ok: + print("ERROR: a response with no acknowledgement map must count as accepted") + failed = True + assert not failed, "test_hanchu_partial_accept_is_not_treated_as_applied" + + +def test_hanchu_rejected_keys_reads_only_a_present_map(): + """rejected_keys is tolerant of every shape a response could take.""" + failed = False + client = MockHanchu() + cases = [ + (None, []), + ({}, []), + ({"responseSemMap": None}, []), + ({"responseSemMap": {}}, []), + ({"responseSemMap": {"A": "1", "B": "1"}}, []), + ({"responseSemMap": {"A": "1", "B": "0"}}, ["B"]), + ({"responseSemMap": {"B": 2, "A": "no"}}, ["A", "B"]), + ] + for data, expect in cases: + got = client.rejected_keys(data) + if got != expect: + print(f"ERROR: rejected_keys({data!r}) = {got} != {expect}") + failed = True + assert not failed, "test_hanchu_rejected_keys_reads_only_a_present_map" + + +def test_hanchu_entity_routing_does_not_confuse_prefixed_serials(): + """An entity for HC701 must never route to HC70 - that would write to the wrong inverter.""" + failed = False + client = MockHanchu() + client.device_list = ["HC70", "HC701"] + for serial in ("HC70", "HC701"): + entity = client._control_name("number", serial, "battery_schedule_reserve") + got = client._sn_from_entity(entity) + if got != serial: + print(f"ERROR: {entity} routed to {got}, not {serial}") + failed = True + if client._sn_from_entity("number.predbat_hanchu_unknown_battery_schedule_reserve") is not None: + print("ERROR: an unknown serial should not resolve") + failed = True + assert not failed, "test_hanchu_entity_routing_does_not_confuse_prefixed_serials" + + +def test_hanchu_control_events_update_the_local_schedule(): + """Each control entity change lands on the right schedule field, with junk tolerated.""" + failed = False + client = ready() + updates = [ + ("number", "battery_schedule_reserve", 12, lambda s: s["reserve"] == 12), + ("select", "battery_schedule_charge_start_time", "01:30:00", lambda s: s["charge"]["start"] == "01:30:00"), + ("select", "battery_schedule_charge_end_time", "04:30:00", lambda s: s["charge"]["end"] == "04:30:00"), + ("number", "battery_schedule_charge_soc", 95, lambda s: s["charge"]["soc"] == 95), + ("number", "battery_schedule_charge_power", 2600, lambda s: s["charge"]["power"] == 2600), + ("switch", "battery_schedule_charge_enable", "turn_on", lambda s: s["charge"]["enable"] is True), + ("switch", "battery_schedule_export_enable", "turn_on", lambda s: s["export"]["enable"] is True), + ("number", "battery_schedule_export_soc", 20, lambda s: s["export"]["soc"] == 20), + ] + for domain, leaf, value, check in updates: + entity = client._control_name(domain, SN, leaf) + if domain == "switch": + run_async_local(client.switch_event(entity, value)) + elif domain == "select": + run_async_local(client.select_event(entity, value)) + else: + run_async_local(client.number_event(entity, value)) + if not check(client.local_schedule[SN]): + print(f"ERROR: {leaf} = {value} did not reach the schedule: {client.local_schedule[SN]}") + failed = True + # A toggle flips whatever is held, and junk leaves the value alone rather than raising. + entity = client._control_name("switch", SN, "battery_schedule_charge_enable") + run_async_local(client.switch_event(entity, "toggle")) + if client.local_schedule[SN]["charge"]["enable"] is not False: + print("ERROR: toggle did not flip the enable switch") + failed = True + entity = client._control_name("number", SN, "battery_schedule_charge_soc") + run_async_local(client.number_event(entity, "unavailable")) + if client.local_schedule[SN]["charge"]["soc"] != 0: + print(f"ERROR: an unavailable number should fall back to 0, got {client.local_schedule[SN]['charge']['soc']}") + failed = True + assert not failed, "test_hanchu_control_events_update_the_local_schedule" + + +def test_hanchu_schedule_read_back_survives_unavailable_entities(): + """Right after a Home Assistant restart the entities can read 'unknown' - that must not raise.""" + failed = False + client = ready() + for leaf in ("battery_schedule_charge_soc", "battery_schedule_charge_power", "battery_schedule_reserve"): + client.state[client._control_name("number", SN, leaf)] = "unavailable" + got = run_async_local(client.get_schedule_settings_ha(SN)) + if got["reserve"] != 0 or got["charge"]["soc"] != 0 or got["charge"]["power"] != 0: + print(f"ERROR: unavailable entities did not fall back to 0: {got}") + failed = True + # A genuinely cold start lands on exactly the empty-schedule shape, so nothing is overwritten. + cold = ready() + if run_async_local(cold.get_schedule_settings_ha(SN)) != cold._empty_schedule(): + print("ERROR: a cold read should equal _empty_schedule()") + failed = True + assert not failed, "test_hanchu_schedule_read_back_survives_unavailable_entities" + + +def test_hanchu_collapsed_window_is_disabled_not_wrapped(): + """An inverted or zero-length window is written as disabled, and the decision is logged.""" + failed = False + client = ready() + payload = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "05:00:00", "end": "04:00:00"})) + # Slot 1 is absent (never written before, nothing to put in it) rather than wrapped around. + if payload.get(HANCHU_KEY_CHARGE_START.format(1), HANCHU_SLOT_DISABLED) != HANCHU_SLOT_DISABLED: + print(f"ERROR: an inverted window was written as {payload.get(HANCHU_KEY_CHARGE_START.format(1))}") + failed = True + if not logged(client, "no usable time"): + print("ERROR: the collapsed window was not explained") + failed = True + assert not failed, "test_hanchu_collapsed_window_is_disabled_not_wrapped" + + +def test_hanchu_restore_state_rebuilds_the_range_tuples(): + """Ranges round-trip through JSON as lists, so restore must hand clamp_range tuples again.""" + failed = False + client = MockHanchu() + cached = { + "static": {"device_list": [SN], "device_detail": {SN: {"capacity_kwh": 10.24}}}, + "config": {"device_ranges": {SN: [[HANCHU_KEY_CHARGE_POWER, [0, 7000]]]}, "device_settings": {SN: {"WORK_MODE_CMB": "3"}}}, + "control": {"local_schedule": {SN: schedule()}, "applied_payload": {SN: {HANCHU_KEY_CHARGE_POWER: 2000}}, "control_active": [SN], "last_write_time": {SN: 1234.0}}, + } + + async def load_cache(name): + """Stand in for the Storage component.""" + return cached.get(name, {}) + + client.load_cache = load_cache + run_async_local(client.restore_state()) + if client.device_list != [SN]: + print(f"ERROR: device list not restored: {client.device_list}") + failed = True + if client.device_ranges.get(SN, {}).get(HANCHU_KEY_CHARGE_POWER) != (0, 7000): + print(f"ERROR: range not rebuilt as a tuple: {client.device_ranges}") + failed = True + if client.battery_rate_max(SN) != 7000.0: + print(f"ERROR: the restored range did not reach battery_rate_max: {client.battery_rate_max(SN)}") + failed = True + if client.control_active != {SN} or client.last_write_time.get(SN) != 1234.0: + print(f"ERROR: control state not restored: {client.control_active} {client.last_write_time}") + failed = True + if not client._cache_restored: + print("ERROR: a clean restore should set the guard so it is not repeated") + failed = True + # The restored applied payload is what makes the "only zero our own slots" rule survive a + # restart, and what stops a restart re-sending a payload the device already holds. + if client.applied_payload.get(SN) != {HANCHU_KEY_CHARGE_POWER: 2000}: + print(f"ERROR: applied payload not restored: {client.applied_payload}") + failed = True + assert not failed, "test_hanchu_restore_state_rebuilds_the_range_tuples" + + +def test_hanchu_empty_payload_sends_nothing(): + """An empty batch is a no-op, not a request carrying an empty value dict.""" + failed = False + client = ready() + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client._write_payload(SN, {})) + if not ok or session_cls.return_value.post.call_count: + print(f"ERROR: an empty payload produced ok={ok} calls={session_cls.return_value.post.call_count}") + failed = True + assert not failed, "test_hanchu_empty_payload_sends_nothing" + + +def test_hanchu_mid_window_end_changes_are_sent(): + """Predbat moves a running window's end time; each change must reach the inverter. + + On hardware (batpred#5305) rewriting a slot mid-window works, and 00:00-00:00 stops a running + window at once. A shorten that were deduplicated away would run the battery past the plan. + """ + failed = False + client = ready() + client.set_mock_clock(13 * 60) + start = 12 * 3600 + + def write(end, enable=True): + client.local_schedule[SN] = schedule(export={"enable": enable, "soc": 20, "power": 3000, "start": "12:00:00", "end": end}) + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + body = session_cls.return_value.post.call_args + sent = (body.kwargs.get("json") or {}).get("value", {}) if body else {} + return ok, session_cls.return_value.post.call_count, sent + + for label, end, expected in ( + ("initial", "16:00:00", (start, 16 * 3600)), + ("shortened", "14:00:00", (start, 14 * 3600)), + ("lengthened", "17:00:00", (start, 17 * 3600)), + ("ended now", "13:00:00", (start, 13 * 3600)), + ): + ok, calls, sent = write(end) + got = (sent.get(HANCHU_KEY_DISCHARGE_START.format(1)), sent.get(HANCHU_KEY_DISCHARGE_END.format(1))) + if not ok or calls != 1 or got != expected: + print(f"ERROR: {label}: ok={ok} calls={calls} slot 1 = {got}, expected {expected}") + failed = True + + # Cancelling the running window disables its slot outright. + ok, calls, sent = write("17:00:00", enable=False) + got = (sent.get(HANCHU_KEY_DISCHARGE_START.format(1)), sent.get(HANCHU_KEY_DISCHARGE_END.format(1))) + if not ok or calls != 1 or got != (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED): + print(f"ERROR: cancelled window: ok={ok} calls={calls} slot 1 = {got}") + failed = True + assert not failed, "test_hanchu_mid_window_end_changes_are_sent" + + +def test_hanchu_persistent_write_failure_notifies_once(): + """Three rejected writes in a row notify the user once; an accepted write clears the streak.""" + failed = False + client = ready() + client.write_retry_delay = 0 + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + busy = (200, envelope(success=False, code=300004, msg="device busy")) + for attempt in range(1, 5): + with patched_session([busy, busy]): + run_async_local(client.apply_schedule(SN)) + notified = client.base.call_notify.call_count + expected = 0 if attempt < 3 else 1 + if notified != expected: + print(f"ERROR: after {attempt} failures, {notified} notification(s), expected {expected}") + failed = True + if client.base.had_errors is not True: + print("ERROR: a persistent write failure did not flag a non-fatal error") + failed = True + + with patched_session([(200, envelope(data={}))]): + ok = run_async_local(client.apply_schedule(SN)) + if not ok or client.write_failures.get(SN) or SN in client.write_failure_notified: + print(f"ERROR: an accepted write did not clear the failure streak: {client.write_failures}") + failed = True + if not logged(client, "accepted again"): + print("ERROR: recovery after a notified failure was not logged") + failed = True + assert not failed, "test_hanchu_persistent_write_failure_notifies_once" + + +def test_hanchu_failed_write_is_retried_before_the_pacing_interval(): + """A failed write is retried on the next cycle, not held for the full pacing interval. + + A rejected shorten leaves the inverter running past the plan, so waiting the full interval + costs real energy. A write that SUCCEEDED is still paced normally. + """ + failed = False + client = ready(client=MockHanchu(min_write_interval=300)) + client.write_retry_delay = 0 + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + busy = (200, envelope(success=False, code=300004, msg="device busy")) + with patched_session([busy, busy]): + run_async_local(client.apply_schedule(SN)) + # One cycle later: well inside 300s, but past the failed-write interval. + client.last_write_time[SN] = time.time() - 31 + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if not ok or session_cls.return_value.post.call_count != 1: + print(f"ERROR: a failed write was held by pacing: ok={ok} calls={session_cls.return_value.post.call_count}") + failed = True + + # After that success, an ordinary change is paced for the full interval again. + client.local_schedule[SN] = schedule(charge={"enable": True, "soc": 80, "power": 3000, "start": "01:00:00", "end": "04:00:00"}) + client.last_write_time[SN] = time.time() - 31 + with patched_session([(200, envelope(data={}))]) as session_cls: + ok = run_async_local(client.apply_schedule(SN)) + if ok or session_cls.return_value.post.call_count: + print(f"ERROR: a normal change escaped the 300s pacing: ok={ok} calls={session_cls.return_value.post.call_count}") + failed = True + assert not failed, "test_hanchu_failed_write_is_retried_before_the_pacing_interval" + + +def test_hanchu_overlapping_windows_keep_only_one(): + """The vendor card refuses overlapping charge and discharge slots, so the payload never carries them. + + The window running now is kept; if neither is running, the one that starts first. Windows that only + touch at an edge do not overlap. + """ + failed = False + + def slots(payload, start_key, end_key): + """Return the enabled (start, end) pairs of one direction's slots.""" + pairs = [(payload.get(start_key.format(i)), payload.get(end_key.format(i))) for i in (1, 2, 3)] + return [pair for pair in pairs if pair != (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED)] + + client = ready() + client.set_mock_clock(13 * 60 + 30) + running = schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "13:00:00", "end": "15:00:00"}, export={"enable": True, "soc": 20, "power": 3000, "start": "14:00:00", "end": "16:00:00"}) + payload = client.build_write_payload(SN, running) + if slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) != [(46800, 54000)] or slots(payload, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END): + print(f"ERROR: the running charge window should be kept and the export dropped: {payload}") + failed = True + if not logged(client, "overlap"): + print("ERROR: dropping a window should be logged") + failed = True + + client = ready() + client.set_mock_clock(10 * 60) + future = schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "12:00:00", "end": "14:00:00"}, export={"enable": True, "soc": 20, "power": 3000, "start": "13:00:00", "end": "15:00:00"}) + payload = client.build_write_payload(SN, future) + if not slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) or slots(payload, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END): + print(f"ERROR: neither running, so the earlier charge window should be kept: {payload}") + failed = True + + client = ready() + client.set_mock_clock(10 * 60) + adjacent = schedule(charge={"enable": True, "soc": 90, "power": 2000, "start": "12:00:00", "end": "13:00:00"}, export={"enable": True, "soc": 20, "power": 3000, "start": "13:00:00", "end": "14:00:00"}) + payload = client.build_write_payload(SN, adjacent) + if not slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) or not slots(payload, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END): + print(f"ERROR: windows that only touch should both be kept: {payload}") + failed = True + assert not failed, "test_hanchu_overlapping_windows_keep_only_one" + + +def enabled_slots(payload, start_key, end_key): + """Return {slot index: (start, end)} for one direction's enabled slots.""" + slots = {} + for index in range(1, 4): + pair = (payload.get(start_key.format(index)), payload.get(end_key.format(index))) + if pair != (HANCHU_SLOT_DISABLED, HANCHU_SLOT_DISABLED): + slots[index] = pair + return slots + + +def test_hanchu_hold_opens_a_charge_slot_at_zero_power(): + """A hold (discharge rate 0, no export running) is written as a charge slot covering now with no charge power. + + Hardware (batpred#5305): on a HESS-HY-S-6.0K a zero discharge rate alone did not stop the battery + feeding the house, but an open charge slot at zero charge power held it at 0 W. The slot runs from + the current half hour to an hour on, so it changes - and is written - only every 30 minutes, and if + Predbat stops renewing it the inverter is back in self-use within the hour. + """ + failed = False + client = ready() + client.device_values[SN] = {"soc": 20.0} + client.set_mock_clock(19 * 60 + 47) + payload = client.build_write_payload(SN, schedule(export={"power": 0}, reserve=21)) + if enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) != {3: (19 * 3600 + 1800, 20 * 3600 + 1800)}: + print(f"ERROR: the hold should be a charge slot 19:30-20:30 in slot 3: {enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END)}") + failed = True + if payload.get(HANCHU_KEY_CHARGE_POWER) != 0 or payload.get(HANCHU_KEY_DISCHARGE_POWER) != 0: + print(f"ERROR: the hold should write zero charge and discharge power: {payload.get(HANCHU_KEY_CHARGE_POWER)} / {payload.get(HANCHU_KEY_DISCHARGE_POWER)}") + failed = True + if payload.get(HANCHU_KEY_WORK_MODE) != HANCHU_WORK_MODE_FOR_SCHEDULE: + print("ERROR: the hold slot only runs in User-defined, so the work mode must be written") + failed = True + # The reserve is never written above the SoC: the slot holds the battery, and a reserve above the SoC + # could make the inverter charge from the grid to reach it. + if payload.get(HANCHU_KEY_DISCHARGE_SOC) != 20: + print(f"ERROR: reserve {payload.get(HANCHU_KEY_DISCHARGE_SOC)} should be capped at the 20% SoC") + failed = True + # Near midnight the slot stops at 23:59 rather than wrapping. + client.set_mock_clock(23 * 60 + 50) + payload = client.build_write_payload(SN, schedule(export={"power": 0}, reserve=21)) + if enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) != {3: (23 * 3600 + 1800, HANCHU_SECONDS_PER_DAY - 60)}: + print(f"ERROR: the late hold slot should be 23:30-23:59: {enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END)}") + failed = True + # A charge window Predbat has planned for later keeps slot 1. + client.set_mock_clock(19 * 60) + payload = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "23:30:00", "end": "23:59:00"}, export={"power": 0})) + slots = enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) + if slots.get(1) != (23 * 3600 + 1800, 23 * 3600 + 59 * 60) or 3 not in slots: + print(f"ERROR: Predbat's later window should stay in slot 1 beside the hold in slot 3: {slots}") + failed = True + assert not failed, "test_hanchu_hold_opens_a_charge_slot_at_zero_power" + + +def test_hanchu_hold_inside_a_charge_window_stops_charging_only_at_the_target(): + """Inside Predbat's charge window a zero discharge rate is a hold once the SoC has reached the target, and charging below it.""" + failed = False + client = ready() + client.set_mock_clock(19 * 60 + 30) + window = {"enable": True, "soc": 50, "power": 3000, "start": "19:00:00", "end": "21:00:00"} + client.device_values[SN] = {"soc": 60.0} + payload = client.build_write_payload(SN, schedule(charge=window, export={"power": 0})) + if payload.get(HANCHU_KEY_CHARGE_POWER) != 0 or enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) != {1: (19 * 3600, 21 * 3600)}: + print(f"ERROR: at 60% with a 50% target the window should hold at zero charge power: {payload}") + failed = True + client.device_values[SN] = {"soc": 40.0} + payload = client.build_write_payload(SN, schedule(charge=window, export={"power": 0})) + if payload.get(HANCHU_KEY_CHARGE_POWER) != 3000: + print(f"ERROR: at 40% with a 50% target the window is charging, so the rate stays 3000: {payload.get(HANCHU_KEY_CHARGE_POWER)}") + failed = True + assert not failed, "test_hanchu_hold_inside_a_charge_window_stops_charging_only_at_the_target" + + +def test_hanchu_freeze_export_passes_the_zero_charge_rate_through_without_a_slot(): + """Freeze export (charge rate 0, no window running) adds no slot: the battery must stay free to cover the house. + + Predbat's freeze export exports surplus solar while the battery still covers the house load. Hardware + (batpred#5305): a discharge slot at zero power stopped the battery covering the house, so it is the + wrong tool. Outside a slot the inverter is in self-use, which covers the house; whether a zero charge + rate there stops the battery absorbing solar is still to be live-tested in sunshine. + """ + failed = False + client = ready() + client.device_values[SN] = {"soc": 60.0} + client.set_mock_clock(12 * 60 + 10) + payload = client.build_write_payload(SN, schedule(charge={"power": 0}, reserve=10)) + if enabled_slots(payload, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END) or enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END): + print(f"ERROR: freeze export must open no slot: {payload}") + failed = True + if payload.get(HANCHU_KEY_CHARGE_POWER) != 0 or payload.get(HANCHU_KEY_DISCHARGE_POWER) != 5000: + print(f"ERROR: freeze export should pass the zero charge rate through and leave discharge at 5000: {payload.get(HANCHU_KEY_CHARGE_POWER)} / {payload.get(HANCHU_KEY_DISCHARGE_POWER)}") + failed = True + if HANCHU_KEY_WORK_MODE in payload: + print("ERROR: with no slot there is no window to run, so the work mode is left alone") + failed = True + assert not failed, "test_hanchu_freeze_export_passes_the_zero_charge_rate_through_without_a_slot" + + +def test_hanchu_no_rate_signal_adds_no_slot(): + """Demand mode (both rates set) and an unknown schedule (both zero) open no slot and leave the work mode alone.""" + failed = False + client = ready() + client.device_values[SN] = {"soc": 60.0} + client.set_mock_clock(12 * 60) + for label, plan in (("demand", schedule()), ("unknown", schedule(charge={"power": 0}, export={"power": 0}))): + payload = client.build_write_payload(SN, plan) + if enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END) or enabled_slots(payload, HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_DISCHARGE_END) or HANCHU_KEY_WORK_MODE in payload: + print(f"ERROR: {label} should open no slot: {payload}") + failed = True + assert not failed, "test_hanchu_no_rate_signal_adds_no_slot" + + +def run_hanchu_control_tests(my_predbat): + """Run all Hanchu control-logic tests.""" + failed = False + for name, fn in [ + ("charge_window", test_hanchu_charge_window_fills_slot_one), + ("midnight_split", test_hanchu_midnight_spanning_window_uses_slot_two), + ("soc_clamping", test_hanchu_soc_limits_are_clamped_and_explained_once), + ("freeze_rate", test_hanchu_freeze_sends_a_zero_rate_unclamped), + ("discharge_floor", test_hanchu_discharge_floor_tracks_the_active_export_window), + ("slot_ownership", test_hanchu_every_unused_slot_is_disabled), + ("write_serialised", test_hanchu_concurrent_writes_are_serialised), + ("mid_window_changes", test_hanchu_mid_window_end_changes_are_sent), + ("failure_notify", test_hanchu_persistent_write_failure_notifies_once), + ("failed_write_retry", test_hanchu_failed_write_is_retried_before_the_pacing_interval), + ("work_mode", test_hanchu_work_mode_is_asserted_only_with_a_window_and_only_when_enabled), + ("batched_write", test_hanchu_write_is_one_call_carrying_every_setting), + ("change_detection", test_hanchu_unchanged_payload_sends_nothing), + ("button_not_forced", test_hanchu_write_button_does_not_force), + ("read_only", test_hanchu_read_only_blocks_the_reconcile_write), + ("reconcile_gate", test_hanchu_reconcile_ignores_a_device_predbat_does_not_drive), + ("control_disabled", test_hanchu_control_disabled_writes_nothing), + ("pacing", test_hanchu_pacing_holds_a_change_rather_than_dropping_it), + ("rejected_write", test_hanchu_rejected_write_is_retried_once_then_paced), + ("partial_accept", test_hanchu_partial_accept_is_not_treated_as_applied), + ("rejected_keys", test_hanchu_rejected_keys_reads_only_a_present_map), + ("entity_routing", test_hanchu_entity_routing_does_not_confuse_prefixed_serials), + ("control_events", test_hanchu_control_events_update_the_local_schedule), + ("schedule_read_back", test_hanchu_schedule_read_back_survives_unavailable_entities), + ("collapsed_window", test_hanchu_collapsed_window_is_disabled_not_wrapped), + ("restore_state", test_hanchu_restore_state_rebuilds_the_range_tuples), + ("empty_payload", test_hanchu_empty_payload_sends_nothing), + ("overlap", test_hanchu_overlapping_windows_keep_only_one), + ("hold_slot", test_hanchu_hold_opens_a_charge_slot_at_zero_power), + ("hold_in_window", test_hanchu_hold_inside_a_charge_window_stops_charging_only_at_the_target), + ("freeze_export_no_slot", test_hanchu_freeze_export_passes_the_zero_charge_rate_through_without_a_slot), + ("no_rate_signal", test_hanchu_no_rate_signal_adds_no_slot), + ]: + try: + if fn(): + print(f" FAILED: hanchu_control.{name}") + failed = True + except Exception as e: + print(f" EXCEPTION in hanchu_control.{name}: {e}") + import traceback + + traceback.print_exc() + failed = True + return failed diff --git a/apps/predbat/tests/test_hanchu_publish.py b/apps/predbat/tests/test_hanchu_publish.py new file mode 100644 index 000000000..9bd0ab829 --- /dev/null +++ b/apps/predbat/tests/test_hanchu_publish.py @@ -0,0 +1,374 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Hanchu ESS cloud publishing, automatic_config and discovery +# ----------------------------------------------------------------------------- + +"""Tests for what the Hanchu component publishes, binds and reports to the catalogue.""" + +import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) +from config import INVERTER_DEF +from coordinator import CAPABILITY_KEYS +from hanchu import HANCHU_CAPABILITIES +from hanchu_const import HANCHU_KEY_CHARGE_END, HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_CHARGE_START, HANCHU_KEY_DISCHARGE_SOC +from tests.discovery_contract import assert_definition_complete, assert_record_agrees, assert_record_binds_nothing_extra, capture_automatic_config, validated_inverters +from tests.test_hanchu_api import MockHanchu, STATISTICS_SAMPLE, STATUS_SAMPLE +from tests.test_infra import run_async as run_async_local + +SN = "HC240100001" + + +def populated(automatic=True, automatic_ignore_pv=False): + """Return a MockHanchu holding one fully polled device, as run() would after a good cycle.""" + client = MockHanchu(automatic=automatic, automatic_ignore_pv=automatic_ignore_pv) + client.device_list = [SN] + client._apply_status_payload(SN, STATUS_SAMPLE) + client.device_energy[SN] = { + "load_today": STATISTICS_SAMPLE["load"], + "import_today": STATISTICS_SAMPLE["gridImport"], + "export_today": STATISTICS_SAMPLE["gridExport"], + "pv_today": STATISTICS_SAMPLE["pv"], + "battery_charge_today": STATISTICS_SAMPLE["batCharge"], + "battery_discharge_today": STATISTICS_SAMPLE["batDisCharge"], + } + client.device_settings[SN] = {"WORK_MODE_CMB": "3", HANCHU_KEY_CHARGE_START.format(1): 5400, HANCHU_KEY_CHARGE_END.format(1): 16200} + return client + + +def test_hanchu_publish_data_emits_the_sensors_automatic_config_binds(): + """Every sensor the arg bindings point at must actually be published.""" + failed = False + client = populated() + run_async_local(client.publish_data()) + for leaf, state in (("soc", 62.0), ("battery_power", -2400.0), ("grid_power", -1500.0), ("pv_power", 3100.0), ("load_power", 900.0)): + entity = client._sensor_name(SN, leaf) + if entity not in client.published: + print(f"ERROR: {entity} was not published") + failed = True + elif client.published[entity]["state"] != state: + print(f"ERROR: {entity} = {client.published[entity]['state']} != {state}") + failed = True + for leaf in ("load_today", "import_today", "export_today", "pv_today"): + entity = client._sensor_name(SN, leaf) + if entity not in client.published: + print(f"ERROR: {entity} was not published") + failed = True + elif client.published[entity]["attributes"].get("unit_of_measurement") != "kWh": + print(f"ERROR: {entity} is not published in kWh") + failed = True + # Ratings. + if client.published.get(client._sensor_name(SN, "battery_capacity"), {}).get("state") != 10.24: + print("ERROR: the battery capacity rating was not published") + failed = True + for leaf in ("battery_rate_max", "inverter_limit"): + if client._sensor_name(SN, leaf) not in client.published: + print(f"ERROR: {leaf} was not published") + failed = True + # The work mode is the correctness of the whole schedule, so a tester has to be able to see it, + # decoded and with the raw value alongside. + work_mode = client.published.get(client._sensor_name(SN, "work_mode")) + if not work_mode or work_mode["state"] != "User-defined" or work_mode["attributes"].get("raw") != "3": + print(f"ERROR: the work mode sensor is {work_mode}") + failed = True + assert not failed, "test_hanchu_publish_data_emits_the_sensors_automatic_config_binds" + + +def test_hanchu_ratings_are_published_only_when_derivable(): + """An arg pointing at a sensor that never appears is worse than an absent arg.""" + failed = False + client = MockHanchu() + client.device_list = [SN] + # A status payload with no capacity field at all. + client._apply_status_payload(SN, {"batSoc": 0.5, "loadPwr": 500}) + run_async_local(client.publish_data()) + if client._sensor_name(SN, "battery_capacity") in client.published: + print("ERROR: a capacity sensor was published although the device reported none") + failed = True + if client._sensor_name(SN, "station_id") in client.published: + print("ERROR: a station sensor was published although the device reported none") + failed = True + # And the sensors that ARE derivable still appear, so this is not passing for the wrong reason. + if client._sensor_name(SN, "soc") not in client.published: + print("ERROR: the SoC sensor should still be published") + failed = True + assert not failed, "test_hanchu_ratings_are_published_only_when_derivable" + + +def test_hanchu_device_slots_are_published_as_readable_times(): + """The slot table as the DEVICE holds it, in HH:MM:SS with the raw seconds alongside. + + This is the diagnostic that separates "the window was never written" from "the window was + written and the inverter ignored it" - the exact failure the inferred work mode would cause. + """ + failed = False + client = populated() + run_async_local(client.publish_data()) + start = client.published.get(client._sensor_name(SN, "device_charge_slot_1_start")) + if not start or start["state"] != "01:30:00" or start["attributes"].get("raw") != 5400: + print(f"ERROR: the device charge slot 1 start sensor is {start}") + failed = True + end = client.published.get(client._sensor_name(SN, "device_charge_slot_1_end")) + if not end or end["state"] != "04:30:00": + print(f"ERROR: the device charge slot 1 end sensor is {end}") + failed = True + # A slot the device did not report is skipped, not published as zero - publishing zero would + # claim it is disabled when the truth is that it was not read. + if client._sensor_name(SN, "device_charge_slot_2_start") in client.published: + print("ERROR: an unreported slot was published as though it were disabled") + failed = True + assert not failed, "test_hanchu_device_slots_are_published_as_readable_times" + + +def test_hanchu_schedule_controls_advertise_the_narrow_soc_ranges(): + """The min/max on the published numbers tell the user what this hardware will actually take.""" + failed = False + client = populated() + run_async_local(client.publish_schedule_settings_ha(SN)) + charge_soc = client.published.get(client._control_name("number", SN, "battery_schedule_charge_soc")) + if not charge_soc or (charge_soc["attributes"].get("min"), charge_soc["attributes"].get("max")) != (20, 100): + print(f"ERROR: the charge SoC control advertises {charge_soc['attributes'] if charge_soc else None}") + failed = True + for leaf in ("battery_schedule_export_soc", "battery_schedule_reserve"): + control = client.published.get(client._control_name("number", SN, leaf)) + if not control or (control["attributes"].get("min"), control["attributes"].get("max")) != (5, 45): + print(f"ERROR: {leaf} advertises {control['attributes'] if control else None}, expected 5-45") + failed = True + # The time entities must be HH:MM:SS, or inverter.py replaces them with its own dummies and the + # window never reaches the component. + for leaf in ("battery_schedule_charge_start_time", "battery_schedule_export_end_time"): + control = client.published.get(client._control_name("select", SN, leaf)) + if not control or len(str(control["state"]).split(":")) != 3: + print(f"ERROR: {leaf} published {control['state'] if control else None}, not HH:MM:SS") + failed = True + # A menu-supplied range wins over the default here too. + client.device_ranges[SN] = {HANCHU_KEY_CHARGE_POWER: (0, 7000)} + run_async_local(client.publish_schedule_settings_ha(SN)) + power = client.published.get(client._control_name("number", SN, "battery_schedule_charge_power")) + if not power or power["attributes"].get("max") != 7000: + print(f"ERROR: the charge power control did not follow the menu range: {power['attributes'] if power else None}") + failed = True + assert not failed, "test_hanchu_schedule_controls_advertise_the_narrow_soc_ranges" + + +def test_hanchu_automatic_config_binds_the_expected_args(): + """automatic_config registers the inverter type and points every arg at a published entity.""" + failed = False + client = populated() + run_async_local(client.publish_data()) + run_async_local(client.publish_schedule_settings_ha(SN)) + run_async_local(client.automatic_config()) + args = client.base.args + if args.get("inverter_type") != ["HanchuCloud"] or args.get("num_inverters") != 1: + print(f"ERROR: inverter registration {args.get('inverter_type')} {args.get('num_inverters')}") + failed = True + # Every bound entity must be one that has actually been published, or Predbat points at nothing. + for arg in ("soc_percent", "battery_power", "grid_power", "load_power", "pv_power", "load_today", "import_today", "export_today", "pv_today", "soc_max", "battery_rate_max", "inverter_limit"): + value = args.get(arg) + if not value: + print(f"ERROR: arg {arg} was not bound") + failed = True + continue + if value[0] not in client.published: + print(f"ERROR: arg {arg} points at {value[0]}, which was never published") + failed = True + for arg in ( + "reserve", + "charge_start_time", + "charge_end_time", + "charge_limit", + "charge_rate", + "scheduled_charge_enable", + "discharge_start_time", + "discharge_end_time", + "discharge_target_soc", + "discharge_rate", + "scheduled_discharge_enable", + "schedule_write_button", + ): + value = args.get(arg) + if not value or value[0] not in client.published: + print(f"ERROR: control arg {arg} = {value}, which was never published") + failed = True + # The sign flags must be claimed, not inherited: base.args is shared and not namespaced per + # inverter type, so a component that inverts its own grid sensor would otherwise leave that key + # set for this one too and an export would read as an import. + for flag in ("grid_power_invert", "battery_power_invert", "load_power_invert"): + if args.get(flag) != [False]: + print(f"ERROR: {flag} = {args.get(flag)}, should be claimed as [False]") + failed = True + # export_limit is deliberately never bound - the API reports no grid connection cap. + if "export_limit" in args: + print("ERROR: export_limit must not be auto-bound; the API reports no export limit") + failed = True + assert not failed, "test_hanchu_automatic_config_binds_the_expected_args" + + +def test_hanchu_automatic_config_caps_the_reserve_at_the_device_floor(): + """inverter_reserve_max is the discharge floor's ceiling, so Predbat never plans a reserve hold it cannot write. + + DSCHG_BAT_SOC_LMT accepts only 5-45%. Without the cap Predbat assumes 100, decides a Hold + Charging at 80% can be held on the reserve, writes 81 - clamped to 45 - and the battery drains + to 45% before re-charging from the grid. It is a single value because inverter.py reads it + without an index; with several devices the lowest ceiling governs. + """ + failed = False + client = populated() + run_async_local(client.automatic_config()) + if client.base.args.get("inverter_reserve_max") != 45: + print(f"ERROR: inverter_reserve_max {client.base.args.get('inverter_reserve_max')}, expected the default ceiling 45") + failed = True + client = populated() + client.device_list = [SN, "HC240100002"] + client._apply_status_payload("HC240100002", STATUS_SAMPLE) + client.device_energy["HC240100002"] = dict(client.device_energy[SN]) + client.device_ranges["HC240100002"] = {HANCHU_KEY_DISCHARGE_SOC: (5, 40)} + run_async_local(client.automatic_config()) + if client.base.args.get("inverter_reserve_max") != 40: + print(f"ERROR: inverter_reserve_max {client.base.args.get('inverter_reserve_max')}, expected the lower device ceiling 40") + failed = True + assert not failed, "test_hanchu_automatic_config_caps_the_reserve_at_the_device_floor" + + +def test_hanchu_automatic_ignore_pv_skips_the_pv_args(): + """The opt-out is the user's choice, not a fact about the device.""" + failed = False + client = populated(automatic_ignore_pv=True) + run_async_local(client.publish_data()) + run_async_local(client.publish_schedule_settings_ha(SN)) + run_async_local(client.automatic_config()) + for arg in ("pv_power", "pv_today"): + if arg in client.base.args: + print(f"ERROR: {arg} was bound despite hanchu_automatic_ignore_pv") + failed = True + if "soc_percent" not in client.base.args: + print("ERROR: the PV opt-out must not stop the other args being bound") + failed = True + assert not failed, "test_hanchu_automatic_ignore_pv_skips_the_pv_args" + + +def test_hanchu_energy_args_need_every_device_to_report(): + """Binding an arg for only some inverters would silently under-report the whole site.""" + failed = False + client = populated() + second = "HC240100002" + client.device_list.append(second) + client._apply_status_payload(second, STATUS_SAMPLE) + # The second device reports no energy counters at all. + run_async_local(client.publish_data()) + run_async_local(client.automatic_config()) + for arg in ("load_today", "import_today", "export_today", "pv_today"): + if arg in client.base.args: + print(f"ERROR: {arg} was bound although only one of two devices reports it") + failed = True + if not any("not every device reports" in message for message in client.log_messages): + print("ERROR: the unbound energy args were not explained") + failed = True + assert not failed, "test_hanchu_energy_args_need_every_device_to_report" + + +def test_hanchu_capabilities_match_the_inverter_def_row(): + """The discovery record rebuilds the INVERTER_DEF row, so the two must agree exactly.""" + failed = False + row = INVERTER_DEF["HanchuCloud"] + for key in CAPABILITY_KEYS: + # support_feedin_first is absent from the row on purpose, and inverter.py defaults it to + # False, which is what the capability set must therefore claim. + expected = row.get(key, False) + if HANCHU_CAPABILITIES.get(key) != expected: + print(f"ERROR: capability {key} = {HANCHU_CAPABILITIES.get(key)} != INVERTER_DEF's {expected}") + failed = True + extra = set(HANCHU_CAPABILITIES) - set(CAPABILITY_KEYS) + if extra: + print(f"ERROR: HANCHU_CAPABILITIES carries keys the catalogue does not know: {sorted(extra)}") + failed = True + assert not failed, "test_hanchu_capabilities_match_the_inverter_def_row" + + +def test_hanchu_discovery_record_is_complete_and_agrees_with_automatic_config(): + """The record must rebuild its INVERTER_DEF row and describe exactly what automatic_config binds.""" + failed = False + client = populated() + run_async_local(client.publish_data()) + run_async_local(client.publish_schedule_settings_ha(SN)) + captured = capture_automatic_config(client) + report = client.build_discovery() + if report is None: + print("ERROR: build_discovery returned None for a fully polled device") + assert False, "test_hanchu_discovery_record_is_complete_and_agrees_with_automatic_config" + if report.get("automatic") is not True: + print(f"ERROR: the report's automatic flag is {report.get('automatic')}") + failed = True + records = validated_inverters(report) + if len(records) != 1: + print(f"ERROR: expected one inverter record, got {len(records)}") + assert False, "test_hanchu_discovery_record_is_complete_and_agrees_with_automatic_config" + record = records[0] + if record.get("device_id") != "hanchu:{}".format(SN): + print(f"ERROR: device_id {record.get('device_id')}") + failed = True + if record.get("hardware_ids", {}).get("serial") != SN: + print(f"ERROR: hardware_ids {record.get('hardware_ids')}") + failed = True + if record.get("account_ids", {}).get("station") != "ST-9001": + print(f"ERROR: account_ids {record.get('account_ids')}") + failed = True + if sorted(record.get("functions") or []) != ["battery", "solar"]: + print(f"ERROR: functions {record.get('functions')}") + failed = True + ratings = record.get("ratings") or {} + if ratings.get("soc_max") != 10.24: + print(f"ERROR: soc_max rating {ratings.get('soc_max')}") + failed = True + # The record alone has to rebuild the INVERTER_DEF row, or the completeness test proves nothing. + assert_definition_complete(record, INVERTER_DEF["HanchuCloud"]["write_and_poll_sleep"]) + assert_record_agrees(record, captured) + assert_record_binds_nothing_extra(record, captured) + assert not failed, "test_hanchu_discovery_record_is_complete_and_agrees_with_automatic_config" + + +def test_hanchu_discovery_returns_none_before_discovery(): + """refresh_discovery treats None as 'nothing to report, ask again next cycle'.""" + failed = False + client = MockHanchu() + if client.build_discovery() is not None: + print("ERROR: build_discovery should be None with no devices discovered") + failed = True + # A device with no telemetry yet claims battery only, never solar - "solar" is claimed on + # observed PV, not on a nameplate this API never gives. + client.device_list = [SN] + record = (client.build_discovery() or {}).get("inverters", [{}])[0] + if record.get("functions") != ["battery"]: + print(f"ERROR: functions before any PV was seen = {record.get('functions')}") + failed = True + assert not failed, "test_hanchu_discovery_returns_none_before_discovery" + + +def run_hanchu_publish_tests(my_predbat): + """Run all Hanchu publish, automatic_config and discovery tests.""" + failed = False + for name, fn in [ + ("publish_data", test_hanchu_publish_data_emits_the_sensors_automatic_config_binds), + ("ratings_conditional", test_hanchu_ratings_are_published_only_when_derivable), + ("device_slots", test_hanchu_device_slots_are_published_as_readable_times), + ("control_ranges", test_hanchu_schedule_controls_advertise_the_narrow_soc_ranges), + ("automatic_config", test_hanchu_automatic_config_binds_the_expected_args), + ("reserve_max", test_hanchu_automatic_config_caps_the_reserve_at_the_device_floor), + ("ignore_pv", test_hanchu_automatic_ignore_pv_skips_the_pv_args), + ("energy_all_or_nothing", test_hanchu_energy_args_need_every_device_to_report), + ("capabilities", test_hanchu_capabilities_match_the_inverter_def_row), + ("discovery_record", test_hanchu_discovery_record_is_complete_and_agrees_with_automatic_config), + ("discovery_none", test_hanchu_discovery_returns_none_before_discovery), + ]: + try: + if fn(): + print(f" FAILED: hanchu_publish.{name}") + failed = True + except Exception as e: + print(f" EXCEPTION in hanchu_publish.{name}: {e}") + import traceback + + traceback.print_exc() + failed = True + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 47c7b850a..93cb9eb82 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -237,6 +237,12 @@ from tests.test_sunsynk_config import run_sunsynk_config_tests from tests.test_sunsynk_parallel import run_sunsynk_parallel_tests from tests.test_alphaess_const import run_alphaess_const_tests +from tests.test_hanchu_const import run_hanchu_const_tests +from tests.test_hanchu_config import run_hanchu_config_tests +from tests.test_hanchu_api import run_hanchu_api_tests +from tests.test_hanchu_control import run_hanchu_control_tests +from tests.test_hanchu_publish import run_hanchu_publish_tests +from tests.test_hanchu_cli import run_hanchu_cli_tests from tests.test_alphaess_api import run_alphaess_api_tests from tests.test_alphaess_config import run_alphaess_config_tests from tests.test_alphaess_publish import run_alphaess_publish_tests @@ -667,6 +673,12 @@ def main(): ("alphaess_publish", run_alphaess_publish_tests, "AlphaESS publish/config tests", False), ("alphaess_control", run_alphaess_control_tests, "AlphaESS control-logic tests", False), ("alphaess_storage", run_alphaess_storage_tests, "AlphaESS storage tests", False), + ("hanchu_const", run_hanchu_const_tests, "Hanchu constants tests", False), + ("hanchu_config", run_hanchu_config_tests, "Hanchu config/INVERTER_DEF tests", False), + ("hanchu_api", run_hanchu_api_tests, "Hanchu API/auth tests", False), + ("hanchu_control", run_hanchu_control_tests, "Hanchu control-logic tests", False), + ("hanchu_publish", run_hanchu_publish_tests, "Hanchu publish/config tests", False), + ("hanchu_cli", run_hanchu_cli_tests, "Hanchu command-line harness helper tests", False), ("enphase_api", run_enphase_api_tests, "Enphase API tests", False), ("solcast", run_solcast_tests, "Solcast API tests", False), ("open_meteo", run_open_meteo_tests, "Open-Meteo solar forecast provider tests", False), diff --git a/templates/hanchu.yaml b/templates/hanchu.yaml new file mode 100644 index 000000000..016af1f32 --- /dev/null +++ b/templates/hanchu.yaml @@ -0,0 +1,451 @@ +# ------------------------------------------------------------------ +# Hanchu ESS Cloud template +# ------------------------------------------------------------------ +# This is the NATIVE Predbat cloud integration - Predbat talks to the Hanchu cloud +# directly and needs no Home Assistant integration and no bridge script. +# +# If you would rather drive the inverter through the hanchu-ess-ha HACS integration +# and a Home Assistant script, use templates/hanchu_cloud.yaml instead; that is a +# different, service-API setup and the two are alternatives, not layers. +# ------------------------------------------------------------------ +--- +pred_bat: + module: predbat + class: PredBat + + # Sets the prefix for all created entities in HA - only change if you want to run more than one instance + prefix: predbat + + # Timezone to work in + timezone: Europe/London + + # Currency, symbol for main currency second symbol for 1/100s e.g. $ c or £ p or e c + currency_symbols: + - '£' + - 'p' + + # Number of threads to use in plan calculation + # Can be auto for automatic, 0 for off or values 1-N for a fixed number + threads: auto + + # XXX: This is a configuration template, delete this line once you edit your configuration + template: True + + # Sets the maximum period of zero load before the gap is filled, default 30 minutes + # To disable set it to 1440 + load_filter_threshold: 30 + + # Hanchu ESS cloud - these are the e-mail address and password you sign into the + # Hanchu phone app with. There is no developer portal and no API key. + # + # EXPERIMENTAL, AND MORE SO THAN THE OTHER CLOUD INTEGRATIONS. Hanchu publish no API + # specification at all: this integration was written by reading open-source Home + # Assistant integrations, so some of its behaviour is deduced rather than documented. + # Nobody on the Predbat project has Hanchu hardware. Please report what you see - + # every request and response is traced to the log while this beds in. + hanchu_account: 'you@example.com' + hanchu_password: 'xxxx' + + # Let Predbat configure the sensors and inverter settings for you + hanchu_automatic: True + + # Set False for monitoring only - Predbat will not write to the inverter. + # switch.predbat_set_read_only also holds back every write, including Predbat's own + # periodic re-apply. + hanchu_control_enable: True + + # IMPORTANT, AND THE MOST LIKELY THING TO NEED CHANGING. + # Nothing documents which Hanchu work mode actually honours the timed charge and + # discharge slots. Predbat deduces it is "User-defined" (mode 3) and sets that mode + # whenever it writes a window. If your inverter accepts the windows and then ignores + # them, set this to False, put the inverter into whichever mode does honour them + # yourself, and please report which one that is. + #hanchu_work_mode_control: True + + # Optional: restrict to one device when the account has several + #hanchu_inverter_sn: 'YOURSERIAL' + + # Optional: Predbat takes the battery power limit from the charge power setting's own + # upper limit, which is the only power ceiling this API reports. Set this if you know + # your pack's real limit. + #hanchu_battery_rate_max: 5000 + + # Optional: the inverter's AC rating, taken from the discharge power setting's upper + # limit. Hanchu units come in 3.68, 5, 6 and 10 kW; set this if Predbat's value is wrong + # for yours (a warning is logged when the device does not report its limits). + #hanchu_inverter_limit: 5000 + + # Optional pacing. Predbat batches every setting change into ONE write per cycle, which + # is what the reference integration does to avoid being rate limited. There is no + # published rate limit, so this default is deliberately mild. + #hanchu_api_delay: 1 + #hanchu_min_write_interval: 60 + + # REQUIRED: the API does not report a grid export limit. If your grid connection is + # capped below the inverter rating (G98/G99), set it here or Predbat will plan exports + # it cannot deliver. One entry per device if your account has more than one. + #export_limit: + # - 3680 + + # NOTE ON SOC LIMITS. This hardware only accepts a grid-charge target of 20-100% and a + # discharge floor of 5-45%. Predbat clamps anything outside those and says so in the + # log; there is no way around it from here. It means a plan that wants to charge to + # only 10%, or to export down to no lower than 60%, cannot be carried out exactly. + # The app's "Maximum Charge SOC" is left as you set it - it limits solar charging too. + + # + + # Some inverters don't turn off when the rate is set to 0, still charge or discharge at around 200w + # The value can be set here in watts to model this (doesn't change operation) + #inverter_battery_rate_min: + # - 200 + + # The highest reserve Predbat may hold the battery on. Hanchu's discharge floor only accepts up + # to 45%, and hanchu_automatic sets this to that ceiling for you - only set it by hand without + # hanchu_automatic, and never above 45, or a hold above 45% drains the battery instead + #inverter_reserve_max : 45 + + # Some batteries tail off their charge rate at high soc% + # enter the charging curve here as a % of the max charge rate for each soc percentage. + # the default is 1.0 (full power) + #battery_charge_power_curve: + # 100 : 0.15 + # 99 : 0.15 + # 98 : 0.22 + # 97 : 0.31 + # 96 : 0.42 + # 95 : 0.48 + # 94 : 0.58 + # 93 : 0.68 + # 92 : 0.77 + # 91 : 0.85 + # 90 : 0.94 + #battery_discharge_power_curve: + # 4: 1.0 + + # Inverter clock skew in minutes, e.g. 1 means it's 1 minute fast and -1 is 1 minute slow + # Separate start and end options are applied to the start and end time windows, mostly as you want to start late (not early) and finish early (not late) + # Separate discharge skew for discharge windows only + inverter_clock_skew_start: 0 + inverter_clock_skew_end: 0 + inverter_clock_skew_discharge_start: 0 + inverter_clock_skew_discharge_end: 0 + + # Clock skew adjusts the Appdaemon time + # This is the time that Predbat takes actions like starting discharge/charging + # Only use this for workarounds if your inverter time is correct but Predbat is somehow wrong (AppDaemon issue) + # 1 means add 1 minute to AppDaemon time, -1 takes it away + clock_skew: 0 + + # Solcast cloud interface, set this or the local interface below + #solcast_host: 'https://api.solcast.com.au/' + #solcast_api_key: 'xxxx' + #solcast_poll_hours: 8 + + # Set these to match solcast sensor names if not using the cloud interface + # The regular expression (re:) makes the solcast bit optional + # If these don't match find your own names in Home Assistant + pv_forecast_today: re:(sensor.(solcast_|)(pv_forecast_|)forecast_today) + pv_forecast_tomorrow: re:(sensor.(solcast_|)(pv_forecast_|)forecast_tomorrow) + pv_forecast_d3: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_3|d3)) + pv_forecast_d4: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_4|d4)) + + # car_charging_energy defines an incrementing sensor which measures the charge added to your car + # is used for car_charging_hold feature to filter out car charging from the previous load data + # Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name + # Also adjust car_charging_energy_scale if it's not in kwH to fix the units + car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.wallbox_portal_added_energy)' + + # car_charging_power is the live charging power of your car charger, in Watts + # It is display only - it drives the car shown on the web interface power flow diagram + # and the predbat.car_charging_power sensor. The plan itself is unaffected by it. + # Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name + car_charging_power: 're:(sensor.myenergi_zappi_[0-9a-z]+_internal_load_ct1|sensor.wallbox_portal_charging_power)' + + # Defines the number of cars modelled by the system, set to 0 for no car + num_cars: 1 + + # car_charging_planned is set to a sensor which when positive indicates the car will charged in the upcoming low rate slots + # This should not be needed if you use Intelligent Octopus slots which will take priority if enabled + # The list of possible values is in car_charging_planned_response + # Auto matches Zappi and Wallbox, or change it for your own + # One entry per car + car_charging_planned: + - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status)' + + car_charging_planned_response: + - 'yes' + - 'on' + - 'true' + - 'connected' + - 'ev connected' + - 'ev ready to charge' + - 'charging' + - 'paused' + - 'waiting for car demand' + - 'waiting for ev' + - 'scheduled' + - 'enabled' + - 'latched' + - 'locked' + - 'plugged in' + - 'waiting' + + # car_charging_now can be set to a sensor that shows the car is charging right now, so that + # Predbat holds the house battery for the car while it charges. It never adds a charging slot. + # Use an on/off sensor (matched against car_charging_now_response), or a charging power sensor + # in W or kW where 200W or more counts as charging - e.g. for a Wallbox charger: + #car_charging_now: + # - sensor.wallbox_portal_charging_power + + # Positive responses for car_charging_now + car_charging_now_response: + - 'yes' + - 'on' + - 'true' + + # To make planned car charging more accurate, either using car_charging_planned or the Octopus Energy plugin, + # specify your battery size in kwh, charge limit % and current car battery soc % sensors/values. + # If you have Intelligent Octopus the battery size and limit will be extracted from the Octopus Energy plugin directly. + # Set the car SoC% if you have it to give an accurate forecast of the cars battery levels. + # One entry per car if you have multiple cars. + #car_charging_battery_size: + # - 75 + #car_charging_limit: + # - 're:number.tsunami_charge_limit' + #car_charging_soc: + # - 're:sensor.tsunami_battery' + + # One per car, when true only one car can charge at once, when False multiple cars can charge at once + #car_charging_exclusive: + # - True + + # If you have Octopus Intelligent Go and are not using the Octopus Direct connection method, enable the intelligent slot information to add to pricing + # Will automatically disable if not found, or comment out to disable fully + # When enabled it overrides the 'car_charging_planned' feature and predict the car charging based on the intelligent plan (unless Octopus intelligent charging is False) + # This matches the intelligent slot from the Octopus Energy integration + octopus_intelligent_slot: 're:(binary_sensor.octopus_energy([0-9a-z_]+|)_intelligent_dispatching)' + octopus_ready_time: 're:((select|time).octopus_energy_([0-9a-z_]+|)_intelligent_target_time)' + octopus_charge_limit: 're:(number.octopus_energy([0-9a-z_]+|)_intelligent_charge_target)' + + # Example alternative configuration for Ohme integration release >=v0.6.1 + #octopus_intelligent_slot: 'binary_sensor.ohme_slot_active' + #octopus_ready_time: 'time.ohme_target_time' + #octopus_charge_limit: 'number.ohme_target_percent' + + # Set this to False if you use Octopus Intelligent slot for car planning but when on another tariff e.g. Agile + #octopus_slot_low_rate: False + + # Carbon Intensity data from National grid + # carbon_postcode: 'SW1 5NA' + # carbon_automatic: True + + # Octopus saving session points to the saving session Sensor in the Octopus plugin, when enabled saving sessions will be at the assumed + # Rate is read automatically from the add-in and converted to pence using the conversion rate below (default is 8) + octopus_saving_session: 're:(event.octopus_energy([0-9a-z_]+|)_(saving_session_events?|power_down_events))' + octopus_saving_session_octopoints_per_penny: 8 + # Minimum octopoints_per_kwh an available saving session must offer for Predbat to auto-join it (default 0, joins any non-negative reward). + octopus_saving_session_min_octopoints_per_kwh: 0 + + # Octopus free session points to the free session Sensor in the Octopus plugin + # Note: You must enable this event sensor in the Octopus Integration in Home Assistant for it to work + octopus_free_session: 're:(event.octopus_energy_([0-9a-z_]+|)_octoplus_(free_electricity_session_events|power_up_events))' + + # Alternative scraper from Octopus web site if the above is not working + # octopus_free_url: 'http://octopus.energy/free-electricity' + + # Enter your Axle VPP API key if you have signed up to the Axle service in the UK + # axle_api_key: "xxxxxxx" + + # Energy rates + # Please set one of these three, if multiple are set then Octopus is used first, second rates_import/rates_export and latest basic metric + + # Set import and export entity to point to the Octopus Energy plugin import and export sensors + # automatically matches your meter number assuming you have only one (no need to edit the below) + # Will be ignored if you don't have the sensor but will error if you do have one and it's incorrect + # Note: To get detailed energy rates you need to go in and manually enable the following events in HA + # event.octopus_energy_electricity_xxxxxxxx_previous_day_rates + # event.octopus_energy_electricity_xxxxxxxx_current_day_rates + # event.octopus_energy_electricity_xxxxxxxx_next_day_rates + # and if you have export enable: + # event.octopus_energy_electricity_xxxxxxxx_export_previous_day_rates + # event.octopus_energy_electricity_xxxxxxxx_export_current_day_rates + # event.octopus_energy_electricity_xxxxxxxx_export_next_day_rates + # Predbat will automatically find the event. entities from the link below to the sensors + metric_octopus_import: 're:(sensor.(octopus_energy_|)electricity_[0-9a-z]+_[0-9a-z]+_current_rate)' + metric_octopus_export: 're:(sensor.(octopus_energy_|)electricity_[0-9a-z]+_[0-9a-z]+_export_current_rate)' + + # Standing charge in pounds, can be set to a sensor or manually entered (e.g. 0.50 is 50p) + # The default below will pick up the standing charge from the Octopus Plugin + # The standing charge only impacts the cost graphs and doesn't change the way Predbat plans + # If you don't want to show the standing charge then just delete this line or set to zero + metric_standing_charge: 're:(sensor.(octopus_energy_|)electricity_[0-9a-z]+_[0-9a-z]+_current_standing_charge)' + + # Energy data service (https://github.com/MTrab/energidataservice) + #metric_energidataservice_import: 'sensor.energi_data_service_import' + #metric_energidataservice_export: 'sensor.energi_data_service_export' + + # Or set your actual rates across time for import and export + # If start/end is missing it's assumed to be a fixed rate + # Gaps are filled with zero rate + #rates_import: + # - start: "00:30:00" + # end: "04:30:00" + # rate: 7.5 + # - start: "04:30:00" + # end: "00:30:00" + # rate: 40.0 + # + #rates_export: + # - rate: 4.2 + + # Can be used instead of the plugin to get import rates directly online + # Overrides metric_octopus_import and rates_import + # See the 'energy rates' part of the documentation for instructions on how to find the correct URL for your tariff and DNO region + # + # rates_import_octopus_url : "https://api.octopus.energy/v1/products/FLUX-IMPORT-23-02-14/electricity-tariffs/E-1R-FLUX-IMPORT-23-02-14-A/standard-unit-rates" + # rates_import_octopus_url : "https://api.octopus.energy/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-A/standard-unit-rates" + + # Overrides metric_octopus_export and rates_export + # rates_export_octopus_url: "https://api.octopus.energy/v1/products/FLUX-EXPORT-23-02-14/electricity-tariffs/E-1R-FLUX-EXPORT-23-02-14-A/standard-unit-rates" + # rates_export_octopus_url: "https://api.octopus.energy/v1/products/AGILE-OUTGOING-19-05-13/electricity-tariffs/E-1R-AGILE-OUTGOING-19-05-13-A/standard-unit-rates/" + + # Import rates can be overridden with rate_import_override + # Export rates can be overridden with rate_export_override + # Use the same format as above, but a date can be included if it just applies for a set day (e.g. Octopus power ups) + # This will override even the Octopus plugin rates if enabled + # + #rates_import_override: + # - date: '2023-09-10' + # start: '14:00:00' + # end: '14:30:00' + # rate: 112 + # load_scaling: 0.8 + + # Days previous sets how many days of load history Predbat searches when forecasting your future house load. + # By default this uses a weighted-bucket forecast that automatically accounts for day-of-week, holiday mode + # and recency (days_previous_auto) - see the documentation for other options, such as picking specific + # days/weights instead, or using Load ML. + days_previous: + - 7 + + # Number of hours forward to forecast, best left as-is unless you have specific reason + forecast_hours: 48 + + # Specify the devices that notifies are sent to, the default is 'notify' which goes to all + #notify_devices: + # - mobile_app_treforsiphone12_2 + + # Battery scaling makes the battery smaller (e.g. 0.9) or bigger than its reported + # If you have an 80% DoD battery that falsely reports it's kwh then set it to 0.8 to report the real figures + # One per inverter + battery_scaling: + - 1.0 + + # Can be used to scale import and export data, used for workarounds + import_export_scaling: 1.0 + + # Export triggers: + # For each trigger give a name, the minutes of export needed and the energy required in that time + # Multiple triggers can be set at once so in total you could use too much energy if all run + # Creates an entity called 'binary_sensor.predbat_export_trigger_' which will be turned On when the condition is valid + # connect this to your automation to start whatever you want to trigger + export_triggers: + - name: 'large' + minutes: 60 + energy: 1.0 + - name: 'small' + minutes: 15 + energy: 0.25 + + # If you have a sensor that gives the energy consumed by your solar diverter then add it here + # this will make the predictions more accurate. It should be an incrementing sensor, it can reset at midnight or not + # It's assumed to be in Kwh but scaling can be applied if need be + #iboost_energy_today: 'sensor.xxxxx' + #iboost_energy_scaling: 1.0 + # Gas rates for comparison + #metric_octopus_gas: 're:(sensor.(octopus_energy_|)gas_[0-9a-z]+_[0-9a-z]+_current_rate)' + + # Nordpool market energy rates + #futurerate_url: 'https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices?date=DATE&market=N2EX_DayAhead&deliveryArea=UK¤cy=GBP' + #futurerate_adjust_import: True + #futurerate_adjust_export: False + #futurerate_peak_start: "16:00:00" + #futurerate_peak_end: "19:00:00" + #futurerate_peak_premium_import: 14 + #futurerate_peak_premium_export: 6.5 + + # Tariff comparison feature + # Adjust this list to the tariffs you want to compare, include your current tariff also + # Octopus region code (https://energy-stats.uk/dno-region-codes-explained/) + #octopus_region: "A" + #compare_list: + # - id: 'current' + # name: 'Current Tariff' + # - id: 'cap_seg' + # name: 'Price cap import/Seg export' + # rates_import: + # - rate: 24.86 + # rates_export: + # - rate: 4.1 + # - id: 'igo_fixed' + # name: 'Intelligent GO import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/INTELLI-BB-VAR-23-03-01/electricity-tariffs/E-1R-INTELLI-BB-VAR-23-03-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'igo_agile' + # name: 'Intelligent GO import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/INTELLI-BB-VAR-23-03-01/electricity-tariffs/E-1R-INTELLI-BB-VAR-23-03-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'go_fixed' + # name: 'GO import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/GO-VAR-BB-23-02-07/electricity-tariffs/E-1R-GO-VAR-BB-23-02-07-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'go_agile' + # name: 'GO import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/GO-VAR-BB-23-02-07/electricity-tariffs/E-1R-GO-VAR-BB-23-02-07-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'agile_fixed' + # name: 'Agile import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'agile_agile' + # name: 'Agile import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'flux' + # name: 'Flux import/Export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/FLUX-IMPORT-23-02-14/electricity-tariffs/E-1R-FLUX-IMPORT-23-02-14-{octopus_region}/standard-unit-rates' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/FLUX-EXPORT-BB-23-02-14/electricity-tariffs/E-1R-FLUX-EXPORT-BB-23-02-14-{octopus_region}/standard-unit-rates' + # - id: 'cosy_fixed' + # name: 'Cosy import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{octopus_region}/standard-unit-rates' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'cosy_agile' + # name: 'Cosy import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{octopus_region}/standard-unit-rates' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'snug_fixed' + # name: 'Snug import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/SNUG-24-11-07/electricity-tariffs/E-1R-SNUG-24-11-07-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + + # Alert feeds - customise to your country and the alert types, severity and keep value + #alerts: + # url: "https://feeds.meteoalarm.org/feeds/meteoalarm-legacy-atom-united-kingdom" + # event: "(Amber|Yellow|Orange|Red).*(Wind|Snow|Fog|Rain|Thunderstorm|Avalanche|Frost|Heat|Coastal event|Flood|Forestfire|Ice|Low temperature|Storm|Tornado|Tsunami|Volcano|Wildfire)" + # severity: "Moderate|Severe|Extreme" + # certainty: "Possible|Likely|Expected" + # keep: 40 + + # Watch list, a list of sensors to watch for changes and then update the plan if they change + # This is useful for things like the Octopus Intelligent Slot sensor so that the plan update as soon as you plugin in + # Only uncomment the items you actually have set up above in apps.yaml, of course you can add your own as well + # Note those using +[] are lists that are appended to this list, whereas {} items are single items only + #watch_list: + # - '{octopus_intelligent_slot}' + # - '{octopus_ready_time}' + # - '{octopus_charge_limit}' + # - '{octopus_saving_session}' + # - '+[car_charging_planned]' + # - '+[car_charging_soc]' + # - '{car_charging_now}'