diff --git a/apps/predbat/components.py b/apps/predbat/components.py index b823e422b..dfd6494bb 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -498,6 +498,9 @@ def load_component_class(component_info): "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"}, + # Logs every API request and response. Off by default: on, it writes thousands of long + # lines a day per device. For a tester proving what hanchu_const.py marks INFERRED. + "api_debug": {"required": False, "default": True, "config": "hanchu_api_debug"}, }, # Gate activation on having an account. Without this the component would start for every # instance, since all individual args are optional. diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 325f2701b..7f9eb9eec 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2395,7 +2395,10 @@ "write_and_poll_sleep": 2, "has_time_window": False, "support_charge_freeze": True, - "support_discharge_freeze": True, + # CHG_PWR_LMT only limits charging from the grid, so a zero charge rate leaves solar filling + # the battery and Freeze Export cannot be carried out (batpred#5665). "Maximum Charge SOC" could + # do it, but only from 50% up and only with a restore the component does not have. + "support_discharge_freeze": False, "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 @@ -2880,6 +2883,7 @@ "hanchu_inverter_limit": {"type": "float"}, "hanchu_api_delay": {"type": "float"}, "hanchu_min_write_interval": {"type": "integer"}, + "hanchu_api_debug": {"type": "boolean"}, "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 index 718ee326a..97a44957e 100644 --- a/apps/predbat/hanchu.py +++ b/apps/predbat/hanchu.py @@ -87,6 +87,8 @@ HANCHU_KEY_DISCHARGE_START, HANCHU_KEY_GRID_CHARGE_SOC, HANCHU_KEY_WORK_MODE, + HANCHU_LOOP_HANDOVER_POLL, + HANCHU_LOOP_HANDOVER_TIMEOUT, HANCHU_MENU_ARRAY_SLOT_TYPES, HANCHU_MENU_CODE, HANCHU_MENU_DATA, @@ -99,8 +101,11 @@ HANCHU_MENU_SECTION, HANCHU_MENU_SIGNAL, HANCHU_MENU_TYPE, + HANCHU_MODEL_FIELD, + HANCHU_MODELS, HANCHU_POWER_STEP, HANCHU_RANGES, + HANCHU_READBACK_SETTLE, HANCHU_REFRESH_MIN_INTERVAL, HANCHU_RETRIES, HANCHU_SECONDS_PER_DAY, @@ -138,12 +143,13 @@ # 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 - +# exposes. support_discharge_freeze is False because CHG_PWR_LMT only limits charging from the grid, +# so a zero charge rate cannot stop solar filling the battery (batpred#5665). 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_discharge_freeze": False, "support_feedin_first": False, "can_span_midnight": False, "charge_discharge_with_rate": False, @@ -155,10 +161,10 @@ 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. + # Trace every request and response. On by default, because this API is undocumented and the trace is + # what a customer's log needs to show when something goes wrong. It writes up to 2000 characters per + # device on every poll (batpred#5672), so hanchu_api_debug: False quietens the log once things work. + # The command-line harness traces too, unless --no-trace. api_debug = True def initialize( @@ -174,6 +180,7 @@ def initialize( inverter_limit=None, api_delay=1, min_write_interval=60, + api_debug=True, **kwargs, ): """Initialise the Hanchu component from its resolved config args. @@ -194,8 +201,11 @@ def initialize( 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 + self.api_debug = bool(api_debug) # Devices already warned that the menu gave no power bounds, so the warning is said once. self.warned_default_power = set() + # Devices whose default-power warning waits on the first status poll - see _warn_if_default_power(). + self.default_power_deferred = set() # Consecutive rejected settings writes per serial, and whether the user has been told. self.write_failures = {} self.write_failure_notified = set() @@ -215,6 +225,9 @@ def initialize( # 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 = {} + # The highest charge and discharge power limit each device has been seen holding, per serial + # and key. See ranges_for() for why this, and not the value held now, raises the menu's ceiling. + self.power_ceiling = {} 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 @@ -254,6 +267,9 @@ def initialize( # 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 + # The event loop run() ticks on, which the session and write_lock belong to. Control events + # can arrive on another loop entirely - see _on_component_loop(). + self._loop = 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: @@ -342,7 +358,16 @@ async def _post(self, endpoint_key, body=None, anonymous=False): 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 + payload = None + if status == 200: + payload = await response.json(content_type=None) + elif status == HANCHU_CODE_UNAUTHORISED: + # The status alone says the session has expired. The body may be empty or an + # HTML error page, and failing to parse it must not stop the re-auth below. + try: + payload = await response.json(content_type=None) + except ValueError: + payload = 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: @@ -362,12 +387,9 @@ async def _post(self, endpoint_key, body=None, anonymous=False): 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. + code = payload.get("code") if isinstance(payload, dict) else None + # An expired token shows up either as the HTTP status or as code 401 inside a 200. Checked + # before the body's shape, because a 401 status needs no body to be acted on. 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)) @@ -375,6 +397,10 @@ async def _post(self, endpoint_key, body=None, anonymous=False): continue return False, None + if not isinstance(payload, dict): + self.log("Warn: Hanchu {} returned a non-object body".format(path)) + 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") @@ -583,7 +609,14 @@ def _apply_status_payload(self, sn, payload): 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))) + # The below-10-means-kW heuristic is only for a field with no unit sibling at all (loadPwr). + # One that has a sibling but arrived without it is read as watts: small grid and battery + # readings are routine, and an idle 3 W battery must not become 3000 W (batpred#5672). + unit_field = HANCHU_TELEMETRY_UNIT_FIELD.get(leaf) + if unit_field is None: + watts = scale_to_watts(raw) + else: + watts = scale_to_watts(raw, payload.get(unit_field), threshold=0) if watts is None: continue values[leaf] = -watts if leaf in HANCHU_TELEMETRY_NEGATE else watts @@ -604,9 +637,46 @@ def _apply_status_payload(self, sn, payload): generator = payload.get(HANCHU_GENERATOR_FIELD) if generator is not None: detail["has_generator"] = str(generator).strip().lower() in ("true", "1") + self._apply_model(sn, payload.get(HANCHU_MODEL_FIELD)) self._apply_device_status(sn, payload.get(HANCHU_DEVICE_STATUS_FIELD)) return True + def _apply_model(self, sn, code): + """Record a device's devModel and its name, saying once what the ratings table makes of it. + + Kept in device_detail, which reaches the static cache at the next static refresh (up to + HANCHU_TTL_STATIC later), so a restart before then has no model until its first status poll - + which runs in the same cycle, before anything is published. A code the table does not know is + kept too: it says why the menu and held values are still in use, and it is what a user would + report to have the model added. + """ + if code is None or str(code).strip() == "": + return + number = int(self._as_float(code, -1)) + detail = self.device_detail.setdefault(sn, {}) + detail["model_code"] = number + model = HANCHU_MODELS.get(number) + if model: + detail["model"] = model[0] + else: + detail.pop("model", None) + if (sn, "model", number) in self._slot_notes_logged: + return + self._slot_notes_logged.add((sn, "model", number)) + if not model: + self.log("Info: Hanchu {} reports devModel {}, which Predbat has no ratings for, so its power limits are taken from the settings menu and the values it holds - please report the model".format(sn, code)) + return + name, battery, ac = model + battery_text = "{} W".format(battery) if battery else "not known (taken from the settings menu)" + self.log("Info: Hanchu {} is a {} (devModel {}): battery limit {}, AC rating {} W".format(sn, name, number, battery_text, ac)) + + def model_rating(self, sn): + """Return (battery limit W, AC rating W) for a device's model, either one None when not known. See HANCHU_MODELS.""" + model = HANCHU_MODELS.get(self.device_detail.get(sn, {}).get("model_code")) + if not model: + return None, None + return model[1], model[2] + def _apply_device_status(self, sn, status): """Record the datalogger's devStatus, saying so once when it goes offline and once when it returns. @@ -632,6 +702,8 @@ async def refresh_power(self): ok, data = await self._post("device_status", body={"sn": sn}) if ok and self._apply_status_payload(sn, data): got_any = True + if sn in self.default_power_deferred: + self._warn_if_default_power(sn, self.device_ranges.get(sn) or {}) else: self.log("Warn: Hanchu {} returned no usable live telemetry".format(sn)) if self.api_delay: @@ -755,34 +827,101 @@ def ranges_for(self, sn): 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. + A power limit's ceiling is raised to the highest value the device has been seen holding, because + a value it holds is one it accepts. The highest seen rather than the value held now: Predbat + overwrites these keys itself, with 0 for a hold or a rate inside the menu's range, so the next + read would otherwise drop the ceiling back to the menu's and the raise would undo itself + (batpred#5672). + + Where the model is known (HANCHU_MODELS), the ceiling is then capped at its battery limit: a held + setting above what the battery can do moves no more power. HARDWARE (batpred#5684): a + HESS-HY-S-6.0K holds DSCHG_PWR_LMT 6000 against the menu's 0-5000 W, and still exports at its + 5000 W battery limit. So the held-value raise only matters for a unit rated above the menu's + maximum, or one whose model is not known. + + The model's rating never raises a ceiling. It sets battery_rate_max and inverter_limit, but + nothing yet shows the cloud accepts a power limit above the menu's maximum that the device has + not itself been seen holding, and a refused key would turn every write into a partial accept + that is resent each cycle. So a 10 kW unit whose menu says 0-5000 is written at most 5000 W + until it is seen holding more (a limit set by an installer, say), and that gap is said once per + key - only once a settings read has shown what the device holds. """ merged = dict(HANCHU_RANGES) merged.update(self.device_ranges.get(sn) or {}) settings = self.device_settings.get(sn) or {} + ceiling = self.power_ceiling.get(sn) or {} + battery_limit = self.model_rating(sn)[0] or 0 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)) + held = int(max(self._as_float(settings.get(key), 0.0), self._as_float(ceiling.get(key), 0.0))) + low, menu_high = merged[key] + high = max(menu_high, held) + if battery_limit: + high = min(high, battery_limit) + merged[key] = (low, high) + if high > menu_high and (sn, "held", key) not in self._clamp_logged: + self._clamp_logged.add((sn, "held", key)) + self.log("Info: Hanchu {} holds {} {} W, above the menu's {} W maximum (a range the menu shares across models), so {} W is used as its limit".format(sn, key, held, menu_high, high)) + if battery_limit > high and sn in self.device_settings and (sn, "rating", key) not in self._clamp_logged: + self._clamp_logged.add((sn, "rating", key)) + self.log("Info: Hanchu {} is rated {} W but {} is only known to accept up to {} W, so Predbat writes no more than that. If the inverter is ever seen holding a higher limit, Predbat will use it".format(sn, battery_limit, key, high)) return merged + def _note_power_ceiling(self, sn, settings): + """Remember the highest charge and discharge power limit a device has been seen holding. See ranges_for().""" + ceiling = self.power_ceiling.setdefault(sn, {}) + for key in (HANCHU_KEY_CHARGE_POWER, HANCHU_KEY_DISCHARGE_POWER): + held = int(self._as_float((settings or {}).get(key), 0.0)) + if held > int(self._as_float(ceiling.get(key), 0.0)): + ceiling[key] = held + + def _note_external_change(self, sn, observed, read_started): + """Forget the applied payload when the device no longer holds it, so the next tick sends it again. + + Change detection compares a new payload only with the last one Predbat sent, so without this a + slot or work mode changed in the Hanchu app, a device reset, or a write the cloud acknowledged + but never delivered would leave the inverter on the wrong settings until the plan itself + changed - the silent drift of batpred#5157 (batpred#5672). + + Only keys the read-back carries are compared, as numbers, since iotGet returns "3" or 70.0 + where the write sent 3 or 70. A read that a write may have overtaken is not judged: one that + began less than HANCHU_READBACK_SETTLE seconds after the last write, or before it. + HARDWARE (batpred#5305): an accepted write reads back within 1-2 s. + """ + applied = self.applied_payload.get(sn) + if not applied or not isinstance(observed, dict): + return + last = self.last_write_time.get(sn) + if last is not None and last > read_started - HANCHU_READBACK_SETTLE: + return + differing = settings_mismatches({key: value for key, value in applied.items() if observed.get(key) is not None}, observed) + if not differing: + return + detail = ", ".join("{}={} (Predbat wrote {})".format(key, got, value) for key, value, got in differing) + self.log("Warn: Hanchu {} settings no longer match what Predbat wrote: {}. The Hanchu app or a device reset may have changed them, so they will be sent again".format(sn, detail)) + self.applied_payload.pop(sn, None) + 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. + Hanchu units run from 3 kW to 50 kW AC: the 5000 W default matches the battery limit of the + single-phase 3.0-6.0K, but not their AC ratings or any larger unit. 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. + + Not said for a model whose battery limit and AC rating are both in HANCHU_MODELS, since then + neither comes from the menu. The config tier runs before the first status poll, which is what + reports the model, so a device whose model has not been heard yet is not warned about on a + guess: refresh_power() asks again straight after that poll. """ 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 + if "model_code" not in self.device_detail.get(sn, {}) and sn not in self.device_values: + self.default_power_deferred.add(sn) + return + self.default_power_deferred.discard(sn) + if None not in self.model_rating(sn): + 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( @@ -811,9 +950,12 @@ async def refresh_config(self): self._warn_if_default_power(sn, self.device_ranges.get(sn) or {}) if self.api_delay: await asyncio.sleep(self.api_delay) + read_started = time.time() 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) + self._note_power_ceiling(sn, data) + self._note_external_change(sn, data, read_started) got_any = True if self.api_delay: await asyncio.sleep(self.api_delay) @@ -829,27 +971,37 @@ def battery_capacity(self, sn): 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. + The model's battery limit from HANCHU_MODELS where it is known (batpred#5664). Otherwise the + CHARGE POWER key's upper bound - the menu's, or a higher value the device holds (see + ranges_for) - which depends on what the limits were last set to rather than on the unit. + 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 + battery_limit = self.model_rating(sn)[0] + if battery_limit: + return float(battery_limit) 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. + The model's AC rating from HANCHU_MODELS where it is known (batpred#5664): on a + HESS-HY-S-3.0K that is 3000 W, below the 5000 W the menu and the battery accept. Otherwise + the discharge power ceiling stands in for it: the largest figure the device will accept, raised + to a higher value the device holds (see ranges_for). That is a setting, not a rating, so it can + be wrong either way - which is why the table exists, and why the warning in + _warn_if_default_power() points at hanchu_inverter_limit. 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 + ac_rating = self.model_rating(sn)[1] + if ac_rating: + return float(ac_rating) return float(self.ranges_for(sn).get(HANCHU_KEY_DISCHARGE_POWER, (0, 0))[1]) def reserve_max(self, devices=None): @@ -1083,9 +1235,8 @@ def build_discovery(self): 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. + - a firmware version: the plain-JSON surface carries none. The model name is reported, as + info.model, only where getDeviceStatus's devModel is one HANCHU_MODELS names. - 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), @@ -1120,6 +1271,7 @@ def build_discovery(self): ratings["inverter_reserve_max"] = self.reserve_max() account_ids = {"station": detail["station_id"]} if detail.get("station_id") else {} + info = {"model": detail["model"]} if detail.get("model") else {} inverters.append( inverter_record( @@ -1130,6 +1282,7 @@ def build_discovery(self): capabilities=dict(HANCHU_CAPABILITIES), hardware_ids={"serial": sn}, account_ids=account_ids, + info=info, ratings=ratings, entities=self._discovery_entities(sn), ) @@ -1307,11 +1460,52 @@ async def _handle_control_event(self, entity_id, value): # 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) + await self._on_component_loop(self.apply_schedule(sn)) return self.update_local_schedule(sn, entity_id, value) await self.publish_schedule_settings_ha(sn) + async def _on_component_loop(self, coro): + """Await coro on the component's own event loop, handing it over when called from another one. + + Predbat presses the write button from its plan thread through ha.py's run_async(), which runs + the event on a throwaway loop of its own. The HTTP session and write_lock belong to the loop + run() ticks on, so using them from the throwaway loop failed every time with "Timeout context + manager should be used inside a task", and each change reached the device only on the next + tick, about a minute later. The same hand-over gateway.py's _publish_raw() makes. + + Runs coro in place when already on the component's loop, or when that loop is not running + (not started yet, or stopped), as before. A handed-over write is waited for for at most + HANCHU_LOOP_HANDOVER_TIMEOUT seconds; it is shielded, so giving up the wait does not cancel it. + + Stopping or restarting the component ends that loop under a waiting caller, two ways. Its + asyncio.run() cancels the write in flight, which is logged and returns False here rather than + escaping as a CancelledError - a cancellation of the caller itself is still re-raised. Or the + loop stops before the handed-over write starts, which would then never run, so the wait is + taken in short steps and abandoned as soon as the loop is no longer running. + """ + loop = self._loop + if loop is None or loop is asyncio.get_running_loop() or loop.is_closed() or not loop.is_running(): + return await coro + future = asyncio.wrap_future(asyncio.run_coroutine_threadsafe(coro, loop)) + deadline = time.monotonic() + HANCHU_LOOP_HANDOVER_TIMEOUT + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + self.log("Warn: Hanchu write is still running on the component's loop after {}s; Predbat will stop waiting for it".format(HANCHU_LOOP_HANDOVER_TIMEOUT)) + return False + try: + return await asyncio.wait_for(asyncio.shield(future), timeout=min(HANCHU_LOOP_HANDOVER_POLL, remaining)) + except asyncio.TimeoutError: + if loop.is_closed() or not loop.is_running(): + self.log("Info: Hanchu component's loop stopped before the write ran; it will be sent when the component next runs") + return False + except asyncio.CancelledError: + if not future.cancelled(): + raise + self.log("Info: Hanchu write was cancelled on the component's loop as the component stopped; it will be sent when the component next runs") + return False + # ------------------------------------------------------------------ # Payload building # ------------------------------------------------------------------ @@ -1414,16 +1608,31 @@ def build_write_payload(self, sn, schedule): signal, extra_direction = self._rate_signal(sn, charge, export) soc = self.device_values.get(sn, {}).get("soc") + 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 + # 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. + # because it is carried by a charge slot at zero power - decided after the slot, so a hold + # with no room for one leaves the plan's charge rate alone (batpred#5672). 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) + # plan asking for less cannot be expressed - _clamp says so once per device. During a hold it + # is capped at the current SoC as a backstop - see _hold_grid_target(). + grid_target = charge.get("soc", 0) + if signal == "hold" and soc is not None: + grid_target = self._hold_grid_target(sn, grid_target, soc, ranges) + payload[HANCHU_KEY_GRID_CHARGE_SOC] = self._clamp(sn, HANCHU_KEY_GRID_CHARGE_SOC, grid_target, 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 @@ -1433,15 +1642,6 @@ def build_write_payload(self, sn, schedule): 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 @@ -1455,6 +1655,27 @@ def build_write_payload(self, sn, schedule): payload[HANCHU_KEY_WORK_MODE] = self.slot_work_mode(sn) return payload + def _hold_grid_target(self, sn, target, soc, ranges): + """Return the grid-charge limit to write during a hold: the charge target, but never above the current SoC. + + The hold slot is a charge slot at zero power, so if that zero did not take - a refused key, + or the interval before a failed write is resent - the slot would charge from the grid into + the plan's target, which for a hold added outside a charge window is a FUTURE window's and can + be 100. With the limit at the SoC there is nothing for it to charge to. It adds no API call, + being part of the same iotSet, and the normal branch writes the real target back once the + hold ends (batpred#5667). It only limits charging from the grid, so solar still charges. + + The limit already written is kept while it is still at or below the SoC, so solar raising + the SoC during a hold does not send a new write at every percent - each one is a slow cloud + call that can collide with the next (error 300004). + """ + low = ranges.get(HANCHU_KEY_GRID_CHARGE_SOC, HANCHU_RANGES[HANCHU_KEY_GRID_CHARGE_SOC])[0] + cap = min(self._as_float(target, 0), max(int(self._as_float(soc, 0)), low)) + written = (self.applied_payload.get(sn) or {}).get(HANCHU_KEY_GRID_CHARGE_SOC) + if written is not None and low <= self._as_float(written, -1) <= cap: + return int(self._as_float(written)) + return int(cap) + def _rate_signal(self, sn, charge, export): """Read a hold out of Predbat's rates, returning (signal, direction of the slot to add). @@ -1464,11 +1685,14 @@ def _rate_signal(self, sn, charge, export): 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. + Freeze-export (a zero charge rate) adds no slot, and Predbat no longer asks for one: INVERTER_DEF + sets support_discharge_freeze False. Predbat's freeze-export exports surplus solar while the + battery still covers the house; HARDWARE (batpred#5305) showed a discharge slot at zero power + stopping the battery covering the house, so no slot fits, and HARDWARE (batpred#5665, an + AC-coupled HESS-HY-S-5.0K) showed CHG_PWR_LMT only limiting charging from the grid, so a zero + charge rate leaves solar filling the battery. "Maximum Charge SOC" held at the current SoC would + stop it, but only from 50% up and only with a guaranteed restore, which this component lacks. + A zero charge rate that still arrives (set by hand) is passed through as it is. 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 @@ -1617,7 +1841,9 @@ async def _write_payload_locked(self, sn, payload, force=False): 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)) + # Every tick lands here, so it is only said while the API trace is on. + if self.api_debug: + self.log("Debug: 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)) @@ -1763,6 +1989,7 @@ async def save_config(self): { "device_ranges": {sn: list(ranges.items()) for sn, ranges in self.device_ranges.items()}, "device_settings": self.device_settings, + "power_ceiling": self.power_ceiling, "slot_model": self.slot_model, "slot_work_modes": self.slot_work_modes, }, @@ -1802,6 +2029,10 @@ async def restore_state(self): # 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.power_ceiling = {sn: dict(keys or {}) for sn, keys in (config.get("power_ceiling") or {}).items()} + # A cache saved before power_ceiling existed still holds the last read, which seeds it. + for sn, settings in self.device_settings.items(): + self._note_power_ceiling(sn, settings) 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) @@ -1840,6 +2071,7 @@ async def run(self, seconds, first): 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. """ + self._loop = asyncio.get_running_loop() 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 diff --git a/apps/predbat/hanchu_const.py b/apps/predbat/hanchu_const.py index 93b737b2f..3cd4a32c7 100644 --- a/apps/predbat/hanchu_const.py +++ b/apps/predbat/hanchu_const.py @@ -251,6 +251,53 @@ # discovery catalogue as an account id. HANCHU_STATION_FIELD = "stationId" +# HARDWARE (batpred#5664): getDeviceStatus carries the model as a code, which the Hanchu web +# console's own pcsEnMap turns into a name - devModel 4 is a HESS-HY-S-5.0K, confirmed on that unit. +# The device-list entry carries no model at all ({"devType", "sn"} only). +HANCHU_MODEL_FIELD = "devModel" + +# devModel -> (model, battery charge/discharge limit W, AC rating W), so battery_rate_max and +# inverter_limit come from the unit rather than from a power setting the menu shares across models +# (batpred#5664). Names from the console's pcsEnMap; ratings from the Hanchu user manuals, both as +# reported on batpred#5664. The codes are not in size order - 30-32 were added later - so this is a +# lookup, never an ordinal. A battery limit of None is one the manual does not state (the 48 V, +# current-limited S-6K-S and S-12K-S): battery_rate_max then falls back to the menu and held values, +# as it does for a code missing here altogether (the HC-HY-SL-*-G2 range the menu also lists). +HANCHU_MODELS = { + 1: ("HESS-HY-S-3.0K", 5000, 3000), + 2: ("HESS-HY-S-3.68K", 5000, 3680), + 3: ("HESS-HY-S-4.0K", 5000, 4000), + 4: ("HESS-HY-S-5.0K", 5000, 5000), + 5: ("HESS-HY-S-6.0K", 5000, 6000), + 6: ("HESS-HY-T-05K", 5000, 5000), + 7: ("HESS-HY-T-06K", 6000, 6000), + 8: ("HESS-HY-T-08K", 8000, 8000), + 9: ("HESS-HY-T-10K", 10000, 10000), + 10: ("HESS-HY-T-12K", 12000, 12000), + 11: ("HESS-HY-T-4K-M", 4000, 4000), + 12: ("HESS-HY-T-5K-M", 5000, 5000), + 13: ("HESS-HY-T-6K-M", 6000, 6000), + 14: ("HESS-HY-T-8K-M", 8000, 8000), + 15: ("HESS-HY-T-10K-M", 10000, 10000), + 16: ("HESS-HY-T-12K-M", 12000, 12000), + 17: ("HESS-HY-T-10K-M4", 10000, 10000), + 18: ("HESS-HY-T-12K-M4", 12000, 12000), + 19: ("HESS-HY-T-15K-M4", 15000, 15000), + 20: ("HESS-HY-T-20K-M4", 20000, 20000), + 21: ("CESS-HY-25K", 25000, 25000), + 22: ("CESS-HY-30K", 30000, 30000), + 23: ("CESS-HY-36K", 36000, 36000), + 24: ("CESS-HY-40K", 40000, 40000), + 25: ("CESS-HY-50K", 50000, 50000), + 26: ("CESS-HY-40K-P", 40000, 40000), + 27: ("CESS-HY-50K-P", 50000, 50000), + 28: ("HESS-HY-S-6K-S", None, 6000), + 29: ("HESS-HY-S-12K-S", None, 12000), + 30: ("HESS-HY-S-7.0K", 7000, 7000), + 31: ("HESS-HY-S-8.0K", 8000, 8000), + 32: ("HESS-HY-S-10.0K", 10000, 10000), +} + # --- 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. @@ -443,11 +490,29 @@ # giving up and leaving the staged changes intact for the user to retry. HANCHU_WRITE_RETRY_DELAY = 5 +# Seconds a write-button press that arrived on another event loop waits for the write it handed to +# the component's own loop (see HanchuAPI._on_component_loop). Long enough to wait out an ordinary +# slow cloud write, while a stuck loop cannot hold Predbat's plan thread indefinitely. It is NOT a +# bound on a write: retries (HANCHU_RETRIES x HANCHU_TIMEOUT plus back-off), the inline retry after +# HANCHU_WRITE_RETRY_DELAY, a re-login or a reconcile write already holding write_lock can all run +# longer. That is fine, because giving up only stops the waiting - the write carries on and is +# recorded in applied_payload when it completes. +HANCHU_LOOP_HANDOVER_TIMEOUT = 120 + +# Seconds between checks, while waiting for a handed-over write, that the component's loop is still +# running - so a write the loop will now never run is given up at once rather than after the timeout. +HANCHU_LOOP_HANDOVER_POLL = 1 + # 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 +# A control read-back is compared with what Predbat last wrote only when the read began at least +# this long after that write, so a read the write may have overtaken is not taken for a change made +# on the device. HARDWARE (batpred#5305): an accepted write reads back within 1-2 s; a minute is margin. +HANCHU_READBACK_SETTLE = 60 + # 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 @@ -501,7 +566,9 @@ def split_window_seconds(start, end): 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. + whatever runs past midnight and is (0, 0) - disabled - when nothing does. An end of exactly + 24:00:00 is clamped to 23:59 like hhmmss_to_seconds(), because 86400 is outside the slot range + and one out-of-range key fails the whole batched iotSet (batpred#5672). Returns ((start1, end1), (start2, end2)). """ @@ -512,7 +579,7 @@ def split_window_seconds(start, end): 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: + 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) diff --git a/apps/predbat/tests/test_hanchu_api.py b/apps/predbat/tests/test_hanchu_api.py index 2c9361cfe..d08c4b0df 100644 --- a/apps/predbat/tests/test_hanchu_api.py +++ b/apps/predbat/tests/test_hanchu_api.py @@ -57,6 +57,10 @@ def __init__(self, account="tester@example.com", password="hunter2", inverter_sn work_mode_control=work_mode_control, api_delay=0, min_write_interval=min_write_interval, + # Off here although it defaults on: every scripted request would otherwise fill log_messages with + # its trace, which the log assertions in these tests do not want to wade through. A test about the + # trace turns it on itself; test_hanchu_api_trace_is_on_unless_turned_off pins the real default. + api_debug=False, ) # 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 @@ -236,6 +240,8 @@ 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") + # MockHanchu turns the trace off for log hygiene; this test is about what it masks when it is on. + client.api_debug = True with patched_session([(200, envelope(data="tok-abc123"))]): run_async_local(client.fetch_token()) joined = "\n".join(client.log_messages) @@ -573,6 +579,8 @@ def test_hanchu_default_power_limit_is_warned_once(): client._token = "tok" client._token_time = time.time() client.device_list = ["HC240100001"] + # A status poll has answered, without a devModel, so no model rating stands in for the menu. + client.device_values["HC240100001"] = {"soc": 50.0} # 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={}))]): @@ -639,6 +647,8 @@ def test_hanchu_failed_menu_warns_the_default_power_is_in_use(): client._token = "tok" client._token_time = time.time() client.device_list = ["HC240100001"] + # A status poll has answered, without a devModel, so no model rating stands in for the menu. + client.device_values["HC240100001"] = {"soc": 50.0} for _ in range(2): with patched_session([(200, envelope(code=500, msg="busy")), (200, envelope(data={}))]): run_async_local(client.refresh_config()) @@ -865,11 +875,11 @@ def 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. + """With no known model, a higher value the device already holds raises the menu's shared 0-5000 W power range. - 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. + A value the device holds is one it accepts, and with no devModel it is the only evidence of what the + unit can take. (On a known model the battery limit caps it - see + test_hanchu_model_battery_limit_caps_a_higher_held_setting.) """ failed = False client = ready_client() @@ -989,6 +999,349 @@ def test_hanchu_retry_delay_default(): assert not failed, "test_hanchu_retry_delay_default" +def test_hanchu_power_ceiling_survives_predbat_overwriting_the_held_value(): + """The raised power ceiling is the highest value ever seen held, not the value held now (batpred#5672). + + Predbat overwrites DSCHG_PWR_LMT itself - with 0 for a hold, or a rate inside the menu's 0-5000 W. + Taking the ceiling from the value held now meant the next config read put it straight back to 5000, + so the 6 kW raise was lost after Predbat's first write and every later plan was derated. + """ + failed = False + client = ready_client() + sn = "HC240100001" + menu = {"energy": {"items": [[{"itemCodeSignal": "CHG_PWR_LMT", "minVal": "0", "maxVal": "5000"}, {"itemCodeSignal": "DSCHG_PWR_LMT", "minVal": "0", "maxVal": "5000"}]]}} + with patched_session([(200, envelope(data=menu)), (200, envelope(data={"CHG_PWR_LMT": "5000", "DSCHG_PWR_LMT": "6000"}))]): + run_async_local(client.refresh_config()) + if client.inverter_limit(sn) != 6000.0: + print(f"ERROR: the first read should raise inverter_limit to 6000: {client.inverter_limit(sn)}") + failed = True + # Predbat has since written a hold, so the device now reads 0 for both. + with patched_session([(200, envelope(data=menu)), (200, envelope(data={"CHG_PWR_LMT": "0", "DSCHG_PWR_LMT": "0"}))]): + run_async_local(client.refresh_config()) + if client.ranges_for(sn).get("DSCHG_PWR_LMT") != (0, 6000) or client.inverter_limit(sn) != 6000.0: + print(f"ERROR: Predbat's own write undid the raise: {client.ranges_for(sn).get('DSCHG_PWR_LMT')} / {client.inverter_limit(sn)}") + failed = True + # The ceiling is persisted with the config cache and restored, so a restart does not lose it either. + saved = {} + + async def save_cache(name, data): + """Stand in for the Storage component's save.""" + saved[name] = data + + async def load_cache(name): + """Stand in for the Storage component's load.""" + return saved.get(name, {}) + + client.save_cache = save_cache + run_async_local(client.save_config()) + restarted = MockHanchu() + restarted.load_cache = load_cache + run_async_local(restarted.restore_state()) + if restarted.ranges_for(sn).get("DSCHG_PWR_LMT") != (0, 6000): + print(f"ERROR: the ceiling did not survive a restart: {restarted.ranges_for(sn).get('DSCHG_PWR_LMT')}") + failed = True + # A cache written before the ceiling existed is seeded from the settings it does hold. + older = MockHanchu() + + async def load_old_cache(name): + """A config cache from before power_ceiling was saved.""" + return {"config": {"device_settings": {sn: {"DSCHG_PWR_LMT": "6000"}}}}.get(name, {}) + + older.load_cache = load_old_cache + run_async_local(older.restore_state()) + older.device_settings[sn] = {"DSCHG_PWR_LMT": "0"} + if older.ranges_for(sn).get("DSCHG_PWR_LMT") != (0, 6000): + print(f"ERROR: an older cache's held 6000 was not kept as the ceiling: {older.ranges_for(sn).get('DSCHG_PWR_LMT')}") + failed = True + assert not failed, "test_hanchu_power_ceiling_survives_predbat_overwriting_the_held_value" + + +def unparseable_response(status, error=None): + """Build a mock response whose body is empty (json() gives None) or, with error, fails to parse.""" + response = create_aiohttp_mock_response(status=status) + + async def _json(*args, **kwargs): + """Return None for an empty body, or raise the parse error given.""" + if error is not None: + raise error + return None + + response.json = _json + return response + + +def test_hanchu_http_401_without_a_json_body_triggers_reauth(): + """An HTTP 401 re-authenticates whatever its body holds (batpred#5672). + + The body was parsed before the status was looked at: an empty 401 body was refused as a non-object + and an HTML one raised and was retried as a transport failure, so neither ever re-authenticated and + every call failed until the proactive refresh, which can be weeks away. + """ + failed = False + for label, error in (("empty", None), ("html", ValueError("Expecting value: line 1 column 1 (char 0)"))): + client = MockHanchu() + client._token = "tok-old" + client._token_time = time.time() + responses = [unparseable_response(401, error), mock_json_response(200, envelope(data="tok-new")), mock_json_response(200, envelope(data=DEVICE_LIST_SAMPLE))] + with patch("aiohttp.ClientSession", return_value=create_aiohttp_mock_session(responses)) as session_cls: + ok, data = run_async_local(client._post("device_list")) + if not ok or data != DEVICE_LIST_SAMPLE: + print(f"ERROR: a 401 with an {label} body did not re-authenticate and retry: ok={ok} data={data!r}") + failed = True + if session_cls.return_value.post.call_count != 3: + print(f"ERROR: a 401 with an {label} body made {session_cls.return_value.post.call_count} requests, expected 3 (call, re-auth, retry)") + failed = True + if session_cls.return_value.post.call_args.kwargs["headers"].get("access-token") != "tok-new": + print(f"ERROR: the retry after an {label} 401 did not carry the new token") + failed = True + assert not failed, "test_hanchu_http_401_without_a_json_body_triggers_reauth" + + +def test_hanchu_watt_heuristic_applies_only_to_fields_without_a_unit_sibling(): + """A small grid or battery reading whose unit is missing is watts, not kW (batpred#5672). + + The below-10-means-kW heuristic is for loadPwr, which has no unit sibling at all. Applied to every + power field, a balanced 6 W grid reading or an idle 3 W battery became 6000 W or 3000 W. + """ + failed = False + client = MockHanchu() + client._apply_status_payload("HC240100001", {"batSoc": 0.5, "batP": 3, "meterPPwr": 6, "pvTtPwr": 0, "loadPwr": 0.9}) + values = client.device_values.get("HC240100001", {}) + for leaf, expect in (("battery_power", -3.0), ("grid_power", -6.0), ("load_power", 900.0)): + if values.get(leaf) != expect: + print(f"ERROR: {leaf} = {values.get(leaf)}, expected {expect}") + failed = True + assert not failed, "test_hanchu_watt_heuristic_applies_only_to_fields_without_a_unit_sibling" + + +def test_hanchu_api_trace_is_on_unless_turned_off(): + """The request/response trace is on by default, so a customer's log carries it, and hanchu_api_debug: False turns it off. + + The API is undocumented, so the trace is what a support request needs. It is long - up to 2000 + characters per device on every poll (batpred#5672) - which is why it can be switched off. + """ + failed = False + client = HanchuAPI.__new__(HanchuAPI) + client.log = lambda message: None + client.initialize(account="a@b.c", password="p") + if not client.api_debug: + print("ERROR: the API trace should default to on") + failed = True + client.initialize(account="a@b.c", password="p", api_debug=False) + if client.api_debug: + print("ERROR: hanchu_api_debug false should turn the trace off") + failed = True + traced = MockHanchu() + traced.log_messages.clear() + traced.debug_api("request", "/x", {"a": 1}) + if traced.log_messages: + print(f"ERROR: the trace logged with api_debug off: {traced.log_messages}") + failed = True + traced.api_debug = True + traced.debug_api("request", "/x", {"a": 1}) + if not traced.log_messages: + print("ERROR: the trace should log with api_debug on") + failed = True + assert not failed, "test_hanchu_api_trace_is_on_unless_turned_off" + + +def test_hanchu_model_code_sets_battery_rate_max_and_inverter_limit(): + """devModel picks the unit's ratings from HANCHU_MODELS, so they no longer depend on held settings (batpred#5664). + + The menu's 0-5000 W power item is shared by 3-10 kW models, and the held limits are whatever they + were last set to, so a HESS-HY-S-3.0K came out at a 5000 W inverter_limit and a 10 kW unit holding + 5000 W at 5000 W for both. + """ + failed = False + sn = "HC240100001" + menu = {"CHG_PWR_LMT": (0, 5000), "DSCHG_PWR_LMT": (0, 5000)} + # (devModel as the API sends it, held settings, battery_rate_max, inverter_limit, writable power ceiling) + cases = [ + # S-3.0K: battery 5000 W but AC only 3000 W - the menu's 5000 W overstated the inverter. + (1, {"CHG_PWR_LMT": "5000", "DSCHG_PWR_LMT": "5000"}, 5000.0, 3000.0, 5000), + # S-6.0K holding a 6000 W charge limit: the battery is still 5000 W, so 5000 is all that is written. + ("5", {"CHG_PWR_LMT": "6000", "DSCHG_PWR_LMT": "4000"}, 5000.0, 6000.0, 5000), + # S-10.0K holding 5000 W: the table raises both ratings, but not what Predbat writes - nothing + # shows the cloud accepts a limit above the menu's that the device has not been seen holding. + (32, {"CHG_PWR_LMT": "5000", "DSCHG_PWR_LMT": "5000"}, 10000.0, 10000.0, 5000), + # S-6K-S: no stated battery limit, so battery_rate_max falls back to the menu; AC rating 6000. + (28, {"CHG_PWR_LMT": "5000", "DSCHG_PWR_LMT": "5000"}, 5000.0, 6000.0, 5000), + ] + for code, held, rate_max, limit, ceiling in cases: + client = MockHanchu() + client.device_ranges[sn] = dict(menu) + client.device_settings[sn] = dict(held) + client._apply_status_payload(sn, dict(STATUS_SAMPLE, devModel=code)) + if client.battery_rate_max(sn) != rate_max or client.inverter_limit(sn) != limit: + print(f"ERROR: devModel {code}: battery_rate_max {client.battery_rate_max(sn)} inverter_limit {client.inverter_limit(sn)}, expected {rate_max} / {limit}") + failed = True + ranges = client.ranges_for(sn) + if ranges["CHG_PWR_LMT"][1] != ceiling: + print(f"ERROR: devModel {code}: charge power ceiling {ranges['CHG_PWR_LMT']}, expected {ceiling}") + failed = True + # The name is recorded and said once. + client = MockHanchu() + for _ in range(2): + client._apply_status_payload(sn, dict(STATUS_SAMPLE, devModel=4)) + if client.device_detail[sn].get("model") != "HESS-HY-S-5.0K": + print(f"ERROR: model {client.device_detail[sn].get('model')}, expected HESS-HY-S-5.0K") + failed = True + said = [message for message in client.log_messages if "is a HESS-HY-S-5.0K" in message] + if len(said) != 1 or "5000 W" not in said[0]: + print(f"ERROR: the model should be logged once with its ratings: {said}") + failed = True + # The user's overrides still win over the table. + client.battery_rate_max_override = 3600.0 + client.inverter_limit_override = 3680.0 + if client.battery_rate_max(sn) != 3600.0 or client.inverter_limit(sn) != 3680.0: + print(f"ERROR: overrides should beat the model table: {client.battery_rate_max(sn)} / {client.inverter_limit(sn)}") + failed = True + assert not failed, "test_hanchu_model_code_sets_battery_rate_max_and_inverter_limit" + + +def test_hanchu_unknown_model_code_falls_back_to_the_menu(): + """A devModel the table does not know keeps the menu and held-value derivation, and asks for a report.""" + failed = False + sn = "HC240100001" + client = MockHanchu() + 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"} + client._apply_status_payload(sn, dict(STATUS_SAMPLE, devModel=99)) + if client.battery_rate_max(sn) != 5000.0 or client.inverter_limit(sn) != 6000.0: + print(f"ERROR: an unknown model should fall back to the menu and held values: {client.battery_rate_max(sn)} / {client.inverter_limit(sn)}") + failed = True + if client.device_detail[sn].get("model_code") != 99 or "model" in client.device_detail[sn]: + print(f"ERROR: the unknown code should be kept without a name: {client.device_detail[sn]}") + failed = True + if not logged(client, "devModel 99, which Predbat has no ratings for"): + print(f"ERROR: an unknown model should be reported: {client.log_messages}") + failed = True + # A payload with no devModel at all leaves things as they were. + bare = MockHanchu() + bare._apply_status_payload(sn, STATUS_SAMPLE) + if "model_code" in bare.device_detail.get(sn, {}) or bare.model_rating(sn) != (None, None): + print(f"ERROR: no devModel should record no model: {bare.device_detail.get(sn)}") + failed = True + assert not failed, "test_hanchu_unknown_model_code_falls_back_to_the_menu" + + +def test_hanchu_default_power_warning_waits_for_the_model(): + """The menu is read before the first status poll, so the default-power warning waits for the model (batpred#5664). + + A model in the table supplies both ratings, so its menu lacking power bounds is not worth a warning; + an unknown one is warned about straight after the status poll that showed it. + """ + failed = False + for code, expect_warning in ((4, False), (99, True), (None, True)): + client = ready_client() + sn = "HC240100001" + with patched_session([(200, envelope(code=500, msg="busy")), (200, envelope(data={}))]): + run_async_local(client.refresh_config()) + if logged(client, "did not report its power limits"): + print(f"ERROR: devModel {code}: warned before the status poll could name the model") + failed = True + status = dict(STATUS_SAMPLE) if code is None else dict(STATUS_SAMPLE, devModel=code) + with patched_session([(200, envelope(data=status))]): + run_async_local(client.refresh_power()) + warnings = [message for message in client.log_messages if "did not report its power limits" in message] + if len(warnings) != (1 if expect_warning else 0): + print(f"ERROR: devModel {code}: expected {1 if expect_warning else 0} default-power warning after the status poll, got {warnings}") + failed = True + # Later polls do not repeat it either way. + with patched_session([(200, envelope(code=500, msg="busy")), (200, envelope(data={}))]): + run_async_local(client.refresh_config()) + with patched_session([(200, envelope(data=status))]): + run_async_local(client.refresh_power()) + if len([message for message in client.log_messages if "did not report its power limits" in message]) != len(warnings): + print(f"ERROR: devModel {code}: the default-power warning was repeated") + failed = True + if sn in client.default_power_deferred: + print(f"ERROR: devModel {code}: the deferral should be cleared once the model is known") + failed = True + assert not failed, "test_hanchu_default_power_warning_waits_for_the_model" + + +def test_hanchu_model_rating_never_raises_what_is_written(): + """A rating above what the device is known to accept is planned with but never written (batpred#5664 review). + + The menu's 0-5000 W item may well be enforced by the cloud, and a refused CHG_PWR_LMT turns every + write into a partial accept that is resent each cycle. So the writes stay within the menu and the + values the device has been seen holding, the gap is said once, and a held value lifts it. + """ + failed = False + sn = "HC240100001" + client = MockHanchu() + client.device_ranges[sn] = {"CHG_PWR_LMT": (0, 5000), "DSCHG_PWR_LMT": (0, 5000)} + client._apply_status_payload(sn, dict(STATUS_SAMPLE, devModel=32)) + # Before any settings read the gap is not reported - the device may yet be holding more. + client.ranges_for(sn) + if logged(client, "is only known to accept"): + print("ERROR: the rating gap should wait for a settings read") + failed = True + client.device_settings[sn] = {"CHG_PWR_LMT": "5000", "DSCHG_PWR_LMT": "5000"} + plan = { + "reserve": 10, + "charge": {"enable": True, "soc": 90, "power": 10000, "start": "01:00:00", "end": "02:00:00"}, + "export": {"enable": False, "soc": 0, "power": 10000, "start": "00:00:00", "end": "00:00:00"}, + } + payload = client.build_write_payload(sn, plan) + if payload.get("CHG_PWR_LMT") != 5000 or payload.get("DSCHG_PWR_LMT") != 5000: + print(f"ERROR: a 10 kW plan should be written at the 5000 W the device is known to accept: {payload.get('CHG_PWR_LMT')} / {payload.get('DSCHG_PWR_LMT')}") + failed = True + if client.battery_rate_max(sn) != 10000.0: + print(f"ERROR: the plan should still be modelled at the model's 10000 W: {client.battery_rate_max(sn)}") + failed = True + gap = [message for message in client.log_messages if "is only known to accept" in message] + client.ranges_for(sn) + if len(gap) != 2 or len([message for message in client.log_messages if "is only known to accept" in message]) != 2: + print(f"ERROR: the gap should be said once for each power key: {gap}") + failed = True + # A device seen holding a higher limit has shown it accepts it, so that becomes writable. + client.device_settings[sn] = {"CHG_PWR_LMT": "10000", "DSCHG_PWR_LMT": "8000"} + client._note_power_ceiling(sn, client.device_settings[sn]) + payload = client.build_write_payload(sn, dict(plan, export=dict(plan["export"], enable=True, start="03:00:00", end="04:00:00"))) + if payload.get("CHG_PWR_LMT") != 10000 or payload.get("DSCHG_PWR_LMT") != 8000: + print(f"ERROR: held limits should become writable: {payload.get('CHG_PWR_LMT')} / {payload.get('DSCHG_PWR_LMT')}") + failed = True + assert not failed, "test_hanchu_model_rating_never_raises_what_is_written" + + +def test_hanchu_model_battery_limit_caps_a_higher_held_setting(): + """On a known model a held power setting above its battery limit is capped at that limit (batpred#5684). + + Hardware: a HESS-HY-S-6.0K holds DSCHG_PWR_LMT 6000 against the menu's 0-5000 W and still exports + at its 5000 W battery limit, so advertising or writing 6000 promised power the battery cannot give. + """ + failed = False + sn = "HC240100001" + client = MockHanchu() + 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"} + client._note_power_ceiling(sn, client.device_settings[sn]) + client._apply_status_payload(sn, dict(STATUS_SAMPLE, devModel=5)) + ranges = client.ranges_for(sn) + if ranges["DSCHG_PWR_LMT"] != (0, 5000) or ranges["CHG_PWR_LMT"] != (0, 5000): + print(f"ERROR: the 6.0K's limits should be capped at its 5000 W battery: {ranges['CHG_PWR_LMT']} / {ranges['DSCHG_PWR_LMT']}") + failed = True + if client.battery_rate_max(sn) != 5000.0 or client.inverter_limit(sn) != 6000.0: + print(f"ERROR: the ratings should stay battery 5000 / AC 6000: {client.battery_rate_max(sn)} / {client.inverter_limit(sn)}") + failed = True + if logged(client, "above the menu") or logged(client, "is only known to accept"): + print(f"ERROR: a capped held value is neither a raise nor a gap worth logging: {client.log_messages}") + failed = True + # A larger model seen holding above the menu keeps that, up to its own battery limit. + big = MockHanchu() + big.device_ranges[sn] = {"CHG_PWR_LMT": (0, 5000), "DSCHG_PWR_LMT": (0, 5000)} + big.device_settings[sn] = {"CHG_PWR_LMT": "12000", "DSCHG_PWR_LMT": "7000"} + big._note_power_ceiling(sn, big.device_settings[sn]) + big._apply_status_payload(sn, dict(STATUS_SAMPLE, devModel=32)) + ranges = big.ranges_for(sn) + if ranges["CHG_PWR_LMT"] != (0, 10000) or ranges["DSCHG_PWR_LMT"] != (0, 7000): + print(f"ERROR: a 10 kW unit should write up to min(held, 10000): {ranges['CHG_PWR_LMT']} / {ranges['DSCHG_PWR_LMT']}") + failed = True + assert not failed, "test_hanchu_model_battery_limit_caps_a_higher_held_setting" + + def run_hanchu_api_tests(my_predbat): """Run all Hanchu API and auth tests.""" failed = False @@ -1025,6 +1378,15 @@ def run_hanchu_api_tests(my_predbat): ("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), + ("power_ceiling_kept", test_hanchu_power_ceiling_survives_predbat_overwriting_the_held_value), + ("http_401_unparseable", test_hanchu_http_401_without_a_json_body_triggers_reauth), + ("watt_heuristic_scope", test_hanchu_watt_heuristic_applies_only_to_fields_without_a_unit_sibling), + ("api_trace_default", test_hanchu_api_trace_is_on_unless_turned_off), + ("model_ratings", test_hanchu_model_code_sets_battery_rate_max_and_inverter_limit), + ("model_unknown", test_hanchu_unknown_model_code_falls_back_to_the_menu), + ("model_rating_not_written", test_hanchu_model_rating_never_raises_what_is_written), + ("model_battery_cap", test_hanchu_model_battery_limit_caps_a_higher_held_setting), + ("model_default_warning", test_hanchu_default_power_warning_waits_for_the_model), ]: try: if fn(): diff --git a/apps/predbat/tests/test_hanchu_config.py b/apps/predbat/tests/test_hanchu_config.py index ec6258afe..15dcb51da 100644 --- a/apps/predbat/tests/test_hanchu_config.py +++ b/apps/predbat/tests/test_hanchu_config.py @@ -52,6 +52,7 @@ def test_hanchu_component_registered(): ("inverter_limit", "hanchu_inverter_limit", None), ("api_delay", "hanchu_api_delay", 1), ("min_write_interval", "hanchu_min_write_interval", 60), + ("api_debug", "hanchu_api_debug", True), ]: info = entry["args"].get(arg) if not info: @@ -133,7 +134,8 @@ def test_hanchu_inverter_def_complete(): "soc_units": "%", "time_button_press": True, "support_charge_freeze": True, - "support_discharge_freeze": True, + # CHG_PWR_LMT only limits grid charging, so a zero charge rate cannot freeze export (batpred#5665). + "support_discharge_freeze": False, # Wrap-around behaviour is undocumented for the slot pairs, so Predbat splits. "can_span_midnight": False, "charge_discharge_with_rate": False, @@ -161,6 +163,7 @@ def test_hanchu_apps_schema_keys(): "hanchu_inverter_limit": "float", "hanchu_api_delay": "float", "hanchu_min_write_interval": "integer", + "hanchu_api_debug": "boolean", } for key, kind in expected.items(): entry = APPS_SCHEMA.get(key) diff --git a/apps/predbat/tests/test_hanchu_const.py b/apps/predbat/tests/test_hanchu_const.py index d61737a2e..65c021ce3 100644 --- a/apps/predbat/tests/test_hanchu_const.py +++ b/apps/predbat/tests/test_hanchu_const.py @@ -185,9 +185,11 @@ def test_split_window_seconds_splits_at_midnight(): 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. + # Exactly midnight is not a wrap: slot 2 stays disabled, and the end is clamped to 23:59 as + # hhmmss_to_seconds does - 86400 is outside the slot range, and one out-of-range key fails the + # whole batched iotSet (batpred#5672). 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): + if first != (79200, HANCHU_SECONDS_PER_DAY - 60) 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" diff --git a/apps/predbat/tests/test_hanchu_control.py b/apps/predbat/tests/test_hanchu_control.py index d4143de78..6acb4f98d 100644 --- a/apps/predbat/tests/test_hanchu_control.py +++ b/apps/predbat/tests/test_hanchu_control.py @@ -10,7 +10,9 @@ import predbat # noqa: F401 (import first - avoids circular import: config.py does `from predbat import THIS_VERSION`) import asyncio +import threading import time +from unittest.mock import patch from hanchu_const import ( HANCHU_KEY_CHARGE_END, HANCHU_KEY_CHARGE_POWER, @@ -322,8 +324,14 @@ def test_hanchu_unchanged_payload_sends_nothing(): 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") + # Said on every tick, so it is only said while the API trace is on (batpred#5672). + if logged(client, "unchanged"): + print("ERROR: the skipped write was logged with the API trace off") + failed = True + client.api_debug = True + run_async_local(client.apply_schedule(SN)) + if not logged(client, "Debug: Hanchu {} settings unchanged".format(SN)): + print("ERROR: with the API trace on, the skipped write should be explained at debug level") failed = True assert not failed, "test_hanchu_unchanged_payload_sends_nothing" @@ -864,12 +872,12 @@ def 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. + """A zero charge rate with 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. + Predbat no longer plans Freeze Export here (support_discharge_freeze is False - hardware, batpred#5665: + CHG_PWR_LMT only limits grid charging, so solar fills the battery regardless), but a zero charge rate + set by hand still arrives. Hardware (batpred#5305): a discharge slot at zero power stopped the battery + covering the house, so the rate is passed through to self-use rather than turned into a slot. """ failed = False client = ready() @@ -902,6 +910,346 @@ def test_hanchu_no_rate_signal_adds_no_slot(): assert not failed, "test_hanchu_no_rate_signal_adds_no_slot" +def _start_owner_loop(): + """Start an event loop on its own thread, as ComponentBase runs the component, and return (loop, thread).""" + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, name="hanchu-owner-loop", daemon=True) + thread.start() + return loop, thread + + +def _stop_owner_loop(loop, thread): + """Stop and close a loop started by _start_owner_loop().""" + loop.call_soon_threadsafe(loop.stop) + thread.join(5) + loop.close() + + +def test_hanchu_write_button_from_another_loop_writes_on_the_component_loop(): + """A write-button press that arrives on another event loop is written on the component's own loop. + + Predbat presses the button from its plan thread through ha.py's run_async(), which runs the event on + a throwaway loop. The HTTP session and the write lock belong to the component's loop, so writing from + the throwaway one failed with "Timeout context manager should be used inside a task", and the change + only reached the device on the component's next tick, about a minute later. + """ + failed = False + client = ready() + button = client._control_name("switch", SN, "battery_schedule_charge_write") + seen = [] + + async def recording_apply(sn, force=False): + """Stand in for apply_schedule, noting which loop it ran on.""" + seen.append(asyncio.get_running_loop()) + return True + + client.apply_schedule = recording_apply + owner, thread = _start_owner_loop() + try: + client._loop = owner + # From the plan thread's throwaway loop: handed over to the component's loop. + run_async_local(client._handle_control_event(button, "turn_on")) + if seen != [owner]: + print(f"ERROR: the write should run on the component's loop, ran on {seen}") + failed = True + if SN not in client.control_active: + print("ERROR: the press should still mark the device as controlled") + failed = True + # From the component's own loop: run in place, not handed to itself. + seen.clear() + asyncio.run_coroutine_threadsafe(client._handle_control_event(button, "turn_on"), owner).result(5) + if seen != [owner]: + print(f"ERROR: on its own loop the write should run in place: {seen}") + failed = True + finally: + _stop_owner_loop(owner, thread) + # With the component's loop gone the write runs where it was asked for, as before. + seen.clear() + run_async_local(client._handle_control_event(button, "turn_on")) + if len(seen) != 1 or seen[0] is owner: + print(f"ERROR: with the component loop closed the write should run on the caller's loop: {seen}") + failed = True + assert not failed, "test_hanchu_write_button_from_another_loop_writes_on_the_component_loop" + + +def test_hanchu_handed_over_write_gives_up_waiting_after_the_timeout(): + """A write handed to the component's loop is not waited on forever, so a stuck loop cannot hang the plan thread - and is not cancelled either.""" + failed = False + client = ready() + button = client._control_name("switch", SN, "battery_schedule_charge_write") + + finished = threading.Event() + + async def slow_apply(sn, force=False): + """Stand in for an apply_schedule that is still waiting on the cloud.""" + await asyncio.sleep(0.3) + finished.set() + return True + + client.apply_schedule = slow_apply + owner, thread = _start_owner_loop() + try: + client._loop = owner + with patch("hanchu.HANCHU_LOOP_HANDOVER_TIMEOUT", 0.05): + started = time.time() + run_async_local(client._handle_control_event(button, "turn_on")) + waited = time.time() - started + if waited > 1: + print(f"ERROR: the caller should stop waiting after the timeout, waited {waited:.2f}s") + failed = True + if not logged(client, "still running on the component's loop"): + print(f"ERROR: giving up should be logged: {client.log_messages}") + failed = True + # Giving up waiting must not cancel the write itself: it carries on to completion. + if not finished.wait(3): + print("ERROR: the write was cancelled when the caller stopped waiting for it") + failed = True + finally: + _stop_owner_loop(owner, thread) + assert not failed, "test_hanchu_handed_over_write_gives_up_waiting_after_the_timeout" + + +def test_hanchu_handed_over_write_cancelled_by_a_stopping_component_is_logged(): + """A handed-over write that the component's loop cancels as it stops is logged, not raised (#5684 review). + + Stopping or restarting the component ends its asyncio.run(), which cancels the write still in + flight. That CancelledError used to escape the hand-over, killing run_async()'s thread with a + traceback instead of saying what happened. + """ + failed = False + client = ready() + button = client._control_name("switch", SN, "battery_schedule_charge_write") + started = threading.Event() + + async def endless_apply(sn, force=False): + """Stand in for an apply_schedule still waiting on the cloud when the component stops.""" + started.set() + await asyncio.sleep(30) + return True + + def cancel_owner_tasks(): + """Cancel every task on the owner loop, as asyncio.run's shutdown does.""" + started.wait(5) + owner.call_soon_threadsafe(lambda: [task.cancel() for task in asyncio.all_tasks(owner)]) + + client.apply_schedule = endless_apply + owner, thread = _start_owner_loop() + try: + client._loop = owner + threading.Thread(target=cancel_owner_tasks, daemon=True).start() + result = run_async_local(client._handle_control_event(button, "turn_on")) + if result is not None: + print(f"ERROR: the press should complete normally: {result}") + failed = True + if not logged(client, "cancelled on the component's loop"): + print(f"ERROR: the cancelled write should be logged: {client.log_messages}") + failed = True + except BaseException as error: + print(f"ERROR: the cancellation escaped the hand-over: {type(error).__name__}") + failed = True + finally: + _stop_owner_loop(owner, thread) + assert not failed, "test_hanchu_handed_over_write_cancelled_by_a_stopping_component_is_logged" + + +def test_hanchu_handed_over_write_stops_waiting_when_the_component_loop_stops(): + """A write handed over just as the component's loop stops is given up at once, not after the full timeout (#5684 review). + + The loop can stop between the is_running() check and running the handed-over callback. The write + then never starts, so waiting HANCHU_LOOP_HANDOVER_TIMEOUT for it only holds the caller. + """ + failed = False + client = ready() + button = client._control_name("switch", SN, "battery_schedule_charge_write") + ran = [] + + async def recording_apply(sn, force=False): + """Stand in for apply_schedule; it must never run here.""" + ran.append(True) + return True + + client.apply_schedule = recording_apply + owner, thread = _start_owner_loop() + release = threading.Event() + + def busy_then_stop(): + """Hold the loop busy until the hand-over is queued, then stop it before the queued write runs.""" + release.wait(5) + owner.stop() + + owner.call_soon_threadsafe(busy_then_stop) + threading.Timer(0.2, release.set).start() + try: + client._loop = owner + with patch("hanchu.HANCHU_LOOP_HANDOVER_TIMEOUT", 30): + started = time.time() + run_async_local(client._handle_control_event(button, "turn_on")) + waited = time.time() - started + if waited > 5: + print(f"ERROR: the caller should stop waiting once the loop has stopped, waited {waited:.1f}s") + failed = True + if ran: + print("ERROR: the write should not have run on a stopped loop") + failed = True + if not logged(client, "component's loop stopped before the write ran"): + print(f"ERROR: giving up should be logged: {client.log_messages}") + failed = True + finally: + thread.join(5) + if not owner.is_closed(): + owner.close() + assert not failed, "test_hanchu_handed_over_write_stops_waiting_when_the_component_loop_stops" + + +def test_hanchu_run_records_the_component_loop(): + """run() records the loop it runs on, so events arriving elsewhere know where to hand writes.""" + failed = False + client = MockHanchu(account="", password="") + + async def tick(): + """Run one tick and return the loop it ran on.""" + await client.run(0, True) + return asyncio.get_running_loop() + + loop = run_async_local(tick()) + if client._loop is not loop: + print(f"ERROR: run() should record its own loop: {client._loop} != {loop}") + failed = True + assert not failed, "test_hanchu_run_records_the_component_loop" + + +def test_hanchu_hold_with_no_room_for_a_slot_keeps_the_charge_rate(): + """A hold that cannot be placed does not zero the charge rate (batpred#5672). + + The rate was zeroed for the hold before _extra_slot() was asked for room, so when there was none the + hold was dropped but CHG_PWR_LMT=0 was still written, leaving every enabled charge slot at 0 W. + """ + failed = False + client = ready() + client.device_values[SN] = {"soc": 60.0} + # 19:47: the hold slot would start at 19:30, but Predbat's own charge window still covers 19:30. + client.set_mock_clock(19 * 60 + 47) + payload = client.build_write_payload(SN, schedule(charge={"enable": True, "soc": 90, "power": 3000, "start": "19:30:00", "end": "19:45:00"}, export={"power": 0})) + if 3 in enabled_slots(payload, HANCHU_KEY_CHARGE_START, HANCHU_KEY_CHARGE_END): + print(f"ERROR: there is no room for a hold slot, so none should be written: {payload}") + failed = True + if payload.get(HANCHU_KEY_CHARGE_POWER) != 3000: + print(f"ERROR: with no hold slot the charge rate should stay at the plan's 3000: {payload.get(HANCHU_KEY_CHARGE_POWER)}") + failed = True + assert not failed, "test_hanchu_hold_with_no_room_for_a_slot_keeps_the_charge_rate" + + +def test_hanchu_settings_changed_on_the_device_are_re_sent(): + """A read-back that no longer matches what Predbat wrote forgets the applied payload, so it is sent again (batpred#5672). + + Change detection compared a new payload only with the last one sent, so a slot edited in the Hanchu + app, a device reset or a write that never landed left the inverter on the wrong settings until the + plan itself changed - the same silent drift as batpred#5157. + """ + 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"}) + with patched_session([(200, envelope(data={}))]): + run_async_local(client.apply_schedule(SN)) + applied = dict(client.applied_payload[SN]) + client.last_write_time[SN] = time.time() - 3600 + menu = {"energy": {"items": []}} + + # The device still holds the write, read back as strings and floats the way iotGet returns them. + same = {key: str(value) for key, value in applied.items()} + same[HANCHU_KEY_DISCHARGE_SOC] = "{:.2f}".format(applied[HANCHU_KEY_DISCHARGE_SOC]) + with patched_session([(200, envelope(data=menu)), (200, envelope(data=same))]): + run_async_local(client.refresh_config()) + if client.applied_payload.get(SN) != applied or logged(client, "no longer match"): + print(f"ERROR: an unchanged read-back was treated as drift: {client.applied_payload.get(SN)}") + failed = True + + # A key the read-back does not carry is not evidence of a change. + partial = {key: value for key, value in same.items() if key != HANCHU_KEY_WORK_MODE} + with patched_session([(200, envelope(data=menu)), (200, envelope(data=partial))]): + run_async_local(client.refresh_config()) + if client.applied_payload.get(SN) != applied: + print("ERROR: a key missing from the read-back was treated as drift") + failed = True + + # The user moves slot 1 in the Hanchu app. + edited = dict(same, **{HANCHU_KEY_CHARGE_START.format(1): "7200"}) + with patched_session([(200, envelope(data=menu)), (200, envelope(data=edited))]): + run_async_local(client.refresh_config()) + if SN in client.applied_payload: + print("ERROR: drift on the device should forget the applied payload") + failed = True + if not logged(client, HANCHU_KEY_CHARGE_START.format(1)): + print("ERROR: the drift warning should name the key that changed") + failed = True + 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 or client.applied_payload.get(SN) != applied: + print(f"ERROR: the next reconcile should re-send the plan: {session_cls.return_value.post.call_count} call(s)") + failed = True + + # A read that a write may have overtaken is not judged: the write landed after the read began. + client.log_messages.clear() + client.last_write_time[SN] = time.time() + 5 + with patched_session([(200, envelope(data=menu)), (200, envelope(data=edited))]): + run_async_local(client.refresh_config()) + if client.applied_payload.get(SN) != applied or logged(client, "no longer match"): + print("ERROR: a read overtaken by a write was judged as drift") + failed = True + assert not failed, "test_hanchu_settings_changed_on_the_device_are_re_sent" + + +def test_hanchu_hold_caps_the_grid_charge_limit_at_the_soc(): + """During a hold the grid-charge limit is never above the SoC, so a 0 W write that did not land cannot grid charge (batpred#5667). + + A hold added outside a charge window used to carry the NEXT window's target, up to 100, beside a charge + slot whose only protection was CHG_PWR_LMT=0. The cap goes in the same iotSet, so it costs no call. + """ + failed = False + client = ready() + client.set_mock_clock(19 * 60 + 47) + later = {"enable": True, "soc": 100, "power": 3000, "start": "23:30:00", "end": "23:59:00"} + client.device_values[SN] = {"soc": 63.4} + payload = client.build_write_payload(SN, schedule(charge=later, export={"power": 0})) + if payload.get(HANCHU_KEY_GRID_CHARGE_SOC) != 63: + print(f"ERROR: a hold at 63.4% should cap the grid-charge limit at 63, not the later window's 100: {payload.get(HANCHU_KEY_GRID_CHARGE_SOC)}") + failed = True + # Solar raising the SoC during the hold does not change the limit already written, so it sends nothing new. + client.applied_payload[SN] = dict(payload) + client.device_values[SN] = {"soc": 70.0} + if client.build_write_payload(SN, schedule(charge=later, export={"power": 0})).get(HANCHU_KEY_GRID_CHARGE_SOC) != 63: + print("ERROR: a written limit still at or below the SoC should be kept, not re-sent at every percent") + failed = True + # A SoC that has fallen below the written limit brings it down with it. + client.device_values[SN] = {"soc": 60.0} + if client.build_write_payload(SN, schedule(charge=later, export={"power": 0})).get(HANCHU_KEY_GRID_CHARGE_SOC) != 60: + print("ERROR: a SoC below the written limit should lower it") + failed = True + # Below the device's 20% floor it is 20, without the clamp warning a plan's own target would earn. + client.applied_payload.pop(SN, None) + client.device_values[SN] = {"soc": 12.0} + if client.build_write_payload(SN, schedule(charge=later, export={"power": 0})).get(HANCHU_KEY_GRID_CHARGE_SOC) != 20: + print("ERROR: a hold below 20% should write the device's 20% floor") + failed = True + if logged(client, "cannot be set to"): + print(f"ERROR: the hold's own cap should not be reported as a clamped plan: {client.log_messages}") + failed = True + # Once the hold ends, the window's real target is written again. + client.device_values[SN] = {"soc": 63.4} + if client.build_write_payload(SN, schedule(charge=later)).get(HANCHU_KEY_GRID_CHARGE_SOC) != 100: + print("ERROR: with no hold the grid-charge limit should carry the window's target") + failed = True + # A hold inside a charge window whose target is met already writes that target, which is below the SoC. + 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} + if client.build_write_payload(SN, schedule(charge=window, export={"power": 0})).get(HANCHU_KEY_GRID_CHARGE_SOC) != 50: + print("ERROR: a met in-window target should still be written as it is") + failed = True + assert not failed, "test_hanchu_hold_caps_the_grid_charge_limit_at_the_soc" + + def run_hanchu_control_tests(my_predbat): """Run all Hanchu control-logic tests.""" failed = False @@ -938,6 +1286,14 @@ def run_hanchu_control_tests(my_predbat): ("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), + ("event_loop_handover", test_hanchu_write_button_from_another_loop_writes_on_the_component_loop), + ("event_loop_handover_timeout", test_hanchu_handed_over_write_gives_up_waiting_after_the_timeout), + ("run_records_loop", test_hanchu_run_records_the_component_loop), + ("event_loop_handover_cancelled", test_hanchu_handed_over_write_cancelled_by_a_stopping_component_is_logged), + ("event_loop_handover_stopped", test_hanchu_handed_over_write_stops_waiting_when_the_component_loop_stops), + ("hold_without_room", test_hanchu_hold_with_no_room_for_a_slot_keeps_the_charge_rate), + ("device_drift", test_hanchu_settings_changed_on_the_device_are_re_sent), + ("hold_grid_cap", test_hanchu_hold_caps_the_grid_charge_limit_at_the_soc), ]: try: if fn(): diff --git a/apps/predbat/tests/test_hanchu_publish.py b/apps/predbat/tests/test_hanchu_publish.py index 9bd0ab829..aa9a2ad05 100644 --- a/apps/predbat/tests/test_hanchu_publish.py +++ b/apps/predbat/tests/test_hanchu_publish.py @@ -345,6 +345,31 @@ def test_hanchu_discovery_returns_none_before_discovery(): assert not failed, "test_hanchu_discovery_returns_none_before_discovery" +def test_hanchu_discovery_record_names_a_known_model(): + """A devModel in HANCHU_MODELS is reported as info.model; an unknown one reports no name (batpred#5664).""" + failed = False + client = MockHanchu() + client.device_list = [SN] + record = (client.build_discovery() or {}).get("inverters", [{}])[0] + if "info" in record: + print(f"ERROR: no model has been seen, so no info should be reported: {record.get('info')}") + failed = True + client._apply_model(SN, 5) + record = (client.build_discovery() or {}).get("inverters", [{}])[0] + if (record.get("info") or {}).get("model") != "HESS-HY-S-6.0K": + print(f"ERROR: devModel 5 should be reported as HESS-HY-S-6.0K: {record.get('info')}") + failed = True + if (record.get("ratings") or {}).get("inverter_limit") != 6000.0: + print(f"ERROR: the record's inverter_limit should be the model's 6000 W: {record.get('ratings')}") + failed = True + client._apply_model(SN, 99) + record = (client.build_discovery() or {}).get("inverters", [{}])[0] + if "info" in record: + print(f"ERROR: an unknown devModel should report no model name: {record.get('info')}") + failed = True + assert not failed, "test_hanchu_discovery_record_names_a_known_model" + + def run_hanchu_publish_tests(my_predbat): """Run all Hanchu publish, automatic_config and discovery tests.""" failed = False @@ -360,6 +385,7 @@ def run_hanchu_publish_tests(my_predbat): ("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), + ("discovery_model", test_hanchu_discovery_record_names_a_known_model), ]: try: if fn(): diff --git a/docs/inverter-setup.md b/docs/inverter-setup.md index 01c3f02ff..9058dc755 100644 --- a/docs/inverter-setup.md +++ b/docs/inverter-setup.md @@ -47,6 +47,7 @@ Once you get everything working please share the configuration as a GitHub issue | [Fox Cloud](#fox-cloud) | Predbat | [fox_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/refs/heads/main/templates/fox_cloud.yaml) | | [Fronius GEN24](#fronius-gen24) | [Fronius](https://www.home-assistant.io/integrations/fronius/) + [fronius-modbus-control](https://github.com/knackerbrot/fronius-modbus-control) | [fronius.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/fronius.yaml) | | [Growatt with Solar Assistant](#growatt-with-solar-assistant) | [Solar Assistant](https://solar-assistant.io/help/home-assistant/setup) | [spa.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/solar_assistant_growatt_spa.yaml) or [sph.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/solar_assistant_growatt_sph.yaml) | + | [Hanchu ESS Cloud](#hanchu-ess-cloud) | Predbat | [hanchu.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/hanchu.yaml) | | [Hanchu iESS](#hanchu-iess) | [hanchu-ess-ha](https://github.com/upton68/hanchu-ess-ha) | [hanchu_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/hanchu_cloud.yaml) | | [Huawei](#huawei) | [Huawei Solar](https://github.com/wlcrs/huawei_solar) | [huawei.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/huawei.yaml) | | [Kostal Plenticore](#kostal-plenticore) | [Kostal Plenticore](https://www.home-assistant.io/integrations/kostal_plenticore) | [kostal.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/kostal.yaml) | @@ -625,9 +626,51 @@ You need to have a Solar Assistant installation Growatt has two popular series of inverters, SPA and SPH. Copy the template that matches your model from templates over the top of your `apps.yaml`, and edit inverter and battery settings as required. Yours may have different entity IDs on Home Assistant. +## Hanchu ESS Cloud + +**Experimental** + +Predbat has a built-in Hanchu ESS Cloud integration that talks to the Hanchu cloud directly, using the e-mail address and password you sign into the Hanchu phone app with. It needs no Home Assistant integration and no bridge script. It is an alternative to the [Hanchu iESS](#hanchu-iess) Service API setup below, not a layer on top of it - use one or the other. + +Hanchu publish no API specification, so this integration was built by reading open-source Home Assistant integrations and has so far been tested on only a couple of units. Please report what you see. Every request and response is traced to the log by default, which is what an issue report needs; once everything works you can set `hanchu_api_debug: False` to quieten the log, and turn it back on before reporting a problem. + +Copy the template [hanchu.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/hanchu.yaml) over your `apps.yaml` and set `hanchu_account` and `hanchu_password` (keep them in `secrets.yaml` - see [Storing secrets](apps-yaml.md#storing-secrets)). With `hanchu_automatic: True` Predbat finds your inverter and wires up its sensors and controls for you. Set `hanchu_control_enable: False` to monitor only. + +### Hanchu ESS Cloud inverter control + +- **Timed slots in User-defined mode.** Predbat drives the inverter through its three charge and three discharge time slots, and sets the User-defined work mode (3) whenever it writes a window. If your inverter accepts the windows but ignores them, set `hanchu_work_mode_control: False`, choose the mode that does honour them yourself, and please report it. +- **Your Hanchu app schedule is cleared.** At its first write Predbat writes all six slots, and sets the ones its plan does not use to 00:00-00:00. In User-defined mode every enabled slot runs, so a slot left over from the app would run alongside Predbat's plan and fight it. Nothing is cleared while Predbat is only monitoring. +- **No Freeze Export.** The charge power limit only limits charging from the grid, so a zero charge rate cannot stop solar charging the battery. Predbat turns Freeze Export off and does not plan it, logging *Inverter 0 does not support discharge freeze - disabled*; forced export is not affected. If any inverter in a multi-inverter system does not support Freeze Export then it is turned off for all of them. +- **Holds.** Freeze charge and other holds are written as a charge slot at zero power. During a hold the grid-charge limit is also capped at the current SoC, so the battery cannot charge from the grid even if the zero-power setting does not take. +- **SoC limits.** This hardware accepts a charge target of only 20-100% and a discharge floor of only 5-45%. Predbat clamps anything outside those ranges and says so in the log. The app's "Maximum Charge SOC" is never changed, because it limits solar charging too. +- **Write pacing.** All of Predbat's settings changes go to the cloud in one call per cycle, at most once per `hanchu_min_write_interval` seconds (default 60). A setting changed in the Hanchu app is noticed when Predbat next reads the settings back (every 30 minutes) and is put back. + +### Hanchu ESS Cloud power ratings + +Predbat looks your model up from the model code the inverter reports, and logs what it found, for example `Hanchu ... is a HESS-HY-S-5.0K (devModel 4): battery limit 5000 W, AC rating 5000 W`. The battery limit becomes `battery_rate_max` and the AC rating becomes `inverter_limit`, which is what Predbat plans with. What Predbat *writes* as a charge or discharge power limit stays within what the inverter is known to accept - the settings menu's maximum (0-5000 W on every unit seen so far), or a higher limit the inverter has been seen holding - and never above the model's battery limit, since a higher setting moves no more power (a HESS-HY-S-6.0K set to 6000 W still exports at 5000 W). On a larger unit Predbat logs that gap once, and forced charge and export windows run at that lower limit until the inverter is seen holding more. For a model Predbat does not know, it logs the code and falls back to the power limits the settings menu reports and the device holds - please report the code and your model so it can be added. Set `hanchu_battery_rate_max` or `hanchu_inverter_limit` to override either figure. + +The API does not report a grid export limit, so if your connection is capped below the inverter rating (G98/G99) set `export_limit` yourself. + +### Verifying with the Hanchu diagnostics CLI + +A standalone CLI is included so you can check the integration against your own system before giving it control. Run it from the `apps/predbat` directory; with no mode option it is read-only: + +```bash +cd apps/predbat +python3 hanchu.py --account you@example.com +``` + +You are prompted for the password (or set `HANCHU_PASSWORD`, or use `--config apps.yaml`). Useful options: + +- `--serial ` - pick one inverter when the account has several +- `--dump-menu FILE` - save the device's full settings menu as JSON +- `--write-test` - write a charge window three hours ahead, read it back and restore it; no energy moves +- `--live-window charge|export|freeze-charge|freeze-export|hold` - run that Predbat state on the inverter now, watch the battery, then restore the previous settings +- `--restore SNAPSHOT` - write back a snapshot that a test saved + ## Hanchu iESS -The Hanchu iESS has no native Predbat integration. Control is implemented via Predbat's generic Service API: Predbat calls four service hooks (`charge_start_service`, `charge_stop_service`, `discharge_start_service`, `discharge_stop_service`), all of which point at a single Home Assistant script that writes the corresponding time slots directly to the device via `hanchuess.device_control`. +The Hanchu iESS can also be driven through Home Assistant with the [hanchu-ess-ha](https://github.com/upton68/hanchu-ess-ha) integration instead of the native [Hanchu ESS Cloud](#hanchu-ess-cloud) integration above. Control is implemented via Predbat's generic Service API: Predbat calls four service hooks (`charge_start_service`, `charge_stop_service`, `discharge_start_service`, `discharge_stop_service`), all of which point at a single Home Assistant script that writes the corresponding time slots directly to the device via `hanchuess.device_control`. Copy the template [hanchu_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/hanchu_cloud.yaml) over your `apps.yaml` and follow the steps below. diff --git a/templates/hanchu.yaml b/templates/hanchu.yaml index 016af1f32..4aa514393 100644 --- a/templates/hanchu.yaml +++ b/templates/hanchu.yaml @@ -40,9 +40,9 @@ pred_bat: # # 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. + # Assistant integrations, so some of its behaviour is deduced rather than documented, + # and it has been tested on only a couple of units. Please report what you see - + # every request and response is traced to the log by default (hanchu_api_debug, below). hanchu_account: 'you@example.com' hanchu_password: 'xxxx' @@ -52,6 +52,15 @@ pred_bat: # 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. + # + # NOTE: once Predbat takes control it writes all three charge and all three discharge + # time slots, and sets the ones its plan does not use to 00:00-00:00. Any schedule you + # set up in the Hanchu app is cleared at Predbat's first write. This is deliberate: in + # User-defined mode every enabled slot runs, so a slot left over from the app would run + # alongside Predbat's plan and fight it. Nothing is cleared while monitoring only. + # + # Freeze Export is not available on Hanchu: the charge power limit only limits charging + # from the grid, so it cannot stop solar charging the battery. hanchu_control_enable: True # IMPORTANT, AND THE MOST LIKELY THING TO NEED CHANGING. @@ -65,14 +74,15 @@ pred_bat: # 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. + # Optional: the battery power limit. Predbat looks your model up from the model code + # the inverter reports and logs what it found ("Hanchu ... is a HESS-HY-S-5.0K"). For a + # model it does not know, it falls back to the charge power setting's upper limit. Set + # this if Predbat's value is wrong for your pack. #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). + # Optional: the inverter's AC rating, from the same model lookup, or for an unknown model + # from the discharge power setting's upper limit (a warning is logged when the device + # reports neither). Set this if Predbat's value is wrong for yours. #hanchu_inverter_limit: 5000 # Optional pacing. Predbat batches every setting change into ONE write per cycle, which @@ -81,6 +91,12 @@ pred_bat: #hanchu_api_delay: 1 #hanchu_min_write_interval: 60 + # Every API request and response is logged by default, because that is what is needed + # to look into a problem with your inverter. It writes thousands of long lines a day: + # once everything is working you can set this to False to quieten the log, and turn it + # back on before reporting an issue. + #hanchu_api_debug: True + # 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. diff --git a/tools/debug-journal.md b/tools/debug-journal.md index b8059fbc5..95a832833 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -262,6 +262,7 @@ Grep for the named symbol rather than trusting a line number. - **Reading restarts out of a `predbat.log` (GH#3969, verified against a reporter's 65k-line v8.39.5 log, 2026-10-19 — before trusting any "Predbat keeps restarting" headline).** The `version currently running` banner is a **per-cycle line** from the release check (`github.py`), fired at the top of every `update_pred()` (~5 min) — 209 banners in 15.5 h was only 12 restarts; count restarts from the app re-init signature instead: `Loading secrets from ...` → `Loading apps.yaml` → `Predbat: Startup predbat` (that string lives in each version's own init path) → the staged `Initialising interface` / `LoadMLComponent: Started` lines, spread over minutes — those `Started` lines are per-restart context, not evidence of an add-on restart by themselves. Abrupt external kills interrupt a mid-write line (a "Best export windows" print cut mid-sentence, the next line one 5-minute cycle later); a graceful app reload logs a terminate path (`Predbat terminating`), so a re-init with no terminate line and cut-off output is a kill from outside the app — the trigger itself (config edit, add-on restart, OOM) is never in predbat.log, ask for the AppDaemon/add-on log. And nothing watches `/addon_configs//predbat_dashboard.yaml`: the only file-watch-restart in the repo is the **standalone** path (`hass.py` `collect_watch_files` — `*.py` plus the real apps.yaml), which the add-on path doesn't run, so a "the dashboard file restarted me" theory should first have the dashboard writes correlated against restart timestamps (writes also *lag* restarts — a fresh instance rewrites it). Pre-v9 Load ML drift worth the same context (v8.39.x): `_do_training()` ran an initial curriculum session and then an **unconditional second** session with no else, so the first save was 30–90+ minutes away on Pi-4-speed hardware and any restart in that window restarted training from zero; main uses a single if/else plus a `progress_callback` that keeps the component alive, and main restores `last_train_time` from the loaded model (`load_ml_component.py`) where v8.39.5 did not — so every app restart re-fired a full curriculum fine-tune — and "No training needed/fine-tune (2h interval), model age is 2.0 hours" on that era is the `last_train_time is None` sentinel printing the interval default, not a real age; a run of "Starting fine-tune training" lines is one multi-pass curriculum session (`train_curriculum()`'s non-first passes), not repeated trainings. - **A module's red run proves only its first new test can fail (seen on PR #5641, runner read on main 697c44ca, 2026-10-20).** `run_kraken_tests()` (`tests/test_kraken.py`) runs every `test_*` in alphabetical `dir()` order and **returns on the first failure**, so a stashed-source red run of `tools/triage_test.sh kraken` shows exactly one FAIL and the other new tests never execute — no evidence any of them can fail. What worked under the triage permission mode (where a single-test direct run is denied and only `triage_test.sh` is allowed): rename the test that just failed to `xtest_...`, re-run, repeat until the run is green, revert the renames before `git stash pop` (~0.1 s per kraken run). Read the specific module's runner before trusting a single red FAIL when more than one test was added — the registry loop in `unit_test.py` aborts on a raise (see Test-order pollution above), which is a different "later tests never ran" shape. - **Replay-probe traps when A/B-ing a plan from a debug yaml (GH#5616, two reruns' worth, 2026-10-18).** (1) Filtering export windows "after 19:00" as `start >= 1140` deletes **tomorrow's** windows too — the dump's window list is one minute-of-day frame holding both days — so filter by the concrete range (`1140 <= start < 1440`) or by end time as well, or the replay metric collapses by hundreds of p and looks like a bug. (2) The simulated SoC is baked at `Prediction(...)` construction — mutating `soc_kw` afterwards never reaches the simulation (two variants scored byte-identical to baseline); to probe "what if the battery were fuller", vary the window/limit arrays or rebuild the `Prediction` object. Also a tag-availability trap when version-matching: `git describe --tags` after `git fetch origin main` still shows the wrong version until tags are fetched too — `git tag --contains HEAD` answers "is HEAD in release X" directly. +- **A component's control events can run on a throwaway event loop (Hanchu, PR #5682, 2026-10-21, reproduced end to end with a real `aiohttp` session).** When Predbat's plan thread presses a component's entity (a schedule-write switch, a select), the press reaches `select_event`/`switch_event`/`number_event` through `ha.py`'s loopback `call_service` → `run_async(trigger_callback(...))`, which runs it with `asyncio.run` on a fresh loop. Anything bound to the component's own loop then breaks there: a persistent `aiohttp.ClientSession` raises `Timeout context manager should be used inside a task` (wrapped by a broad `except` it looks like a transport failure), and an `asyncio.Lock` stops serialising across the two loops. The symptom is a write that fails on every plan cycle and lands a minute later from the component's own `run()` tick. Look for a component that keeps a session or lock in `self` *and* does I/O directly in its event handler. Per-request sessions (alphaess, sunsynk, deye, fox, sigenergy) sidestep it. `gateway.py` `_publish_raw()` and Hanchu `_on_component_loop()` hand the work over with `run_coroutine_threadsafe` onto the loop recorded in `run()`. - **Log noise.** `predbat.log` carries routine `Warn:` lines (config clamps, kernel status, unsupported settings). Don't quote a warning as the root cause unless it lines up with the time the reporter describes. - **HA-side service/entity warnings never appear in `predbat.log`.** Lines like `Referenced entities switch.predbat_set_reserve_enable are missing or not currently available` (logger `homeassistant.helpers.service`, `Source: helpers/target.py:152`) are written by HA core around any app's service calls and land in the **HA core log** - an AppDaemon app's log carries none of them (Predbat's own lines are `Note:`/`Warn:`/info style; on GH#5420 the attached 50k-line `predbat.log` grepped 0 for the string and holds no ERROR/WARNING lines at all). On a startup-race/entity-not-ready report, predbat.log shows what Predbat *did* per cycle; ask for the HA core log for the not-available text instead of waiting for it inside predbat.log. - **Hardware questions.** A unit test settles what the code does, not what an inverter did. Several findings here were only confirmed on live hardware. If the question is hardware behaviour, say the maintainer needs to confirm it rather than running a test to look thorough.