Conversation
There was a problem hiding this comment.
Pull request overview
This PR is a first cleanup step for the Gateway MQTT integration, shifting gateway telemetry publishing toward HA-style entities with consistent attributes, and expanding tests around entity injection and command routing.
Changes:
- Replaced the old protobuf field-path
decode_telemetry()mapping with direct entity injection via_inject_entities()/_inject_inverter_entities()and a centralizedGATEWAY_ATTRIBUTE_TABLE. - Updated the plan-executed hook conversion (
_on_plan_executed) to compute gateway target SoC as a percentage usingsoc_max, and added gateway mode constants. - Significantly expanded
test_gateway.pycoverage for entity injection, automatic config mapping, and select/number/switch event routing.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| apps/predbat/tests/test_gateway.py | Reworks/extends tests to validate _inject_entities(), automatic_config(), and event→command routing behavior. |
| apps/predbat/predbat.py | Passes soc_max and reserve into the on_plan_executed hook for gateway consumers. |
| apps/predbat/gateway.py | Introduces attribute table + entity injection updates, updates plan hook conversion, tightens entity-id routing, and removes decode_telemetry(). |
| # Apply % 24 to prevent hour overflow for overnight windows (e.g. end=1485 → 00:45) | ||
| start_hour = start_minutes // 60 | ||
| start_minute = start_minutes % 60 | ||
| end_hour = end_minutes // 60 | ||
| end_minute = end_minutes % 60 | ||
| # Skip zero-duration windows (start == end after midnight wrap) | ||
| if start_hour == end_hour and start_minute == end_minute: | ||
| self.log(f"Warn: GatewayMQTT: Skipping zero-duration charge window {i} ({start_minutes}-{end_minutes})") |
There was a problem hiding this comment.
The code/comment says “Apply % 24 to prevent hour overflow” for overnight windows, but start_hour/end_hour are computed with integer division only (no modulo). If PredBat provides an overnight end minute > 1440 (e.g. 1485), end_hour becomes 24+ which is an invalid hour for the gateway schedule and also breaks the zero-duration detection after wrap. Apply modulo 24 (and run the zero-duration check after wrapping) before building the plan entry.
| # Time options for schedule select entities (HH:MM:SS, one per minute across 24 h) | ||
| _GATEWAY_BASE_TIME = datetime.datetime.strptime("00:00", "%H:%M") | ||
| _GATEWAY_OPTIONS_TIME = [(_GATEWAY_BASE_TIME + datetime.timedelta(seconds=m * 60)).strftime("%H:%M:%S") for m in range(0, 24 * 60)] |
There was a problem hiding this comment.
_GATEWAY_OPTIONS_TIME builds a 1440-entry options list and is embedded into the attributes for each schedule select. Since _inject_inverter_entities() republishes these attributes on every telemetry update, this can create very large state payloads and unnecessary churn (memory/CPU and potentially HA state/recorder bloat). Consider reducing the granularity (e.g. 5-min steps) and/or only sending the options metadata once (or behind a feature flag) instead of on every state update.
| # Time options for schedule select entities (HH:MM:SS, one per minute across 24 h) | |
| _GATEWAY_BASE_TIME = datetime.datetime.strptime("00:00", "%H:%M") | |
| _GATEWAY_OPTIONS_TIME = [(_GATEWAY_BASE_TIME + datetime.timedelta(seconds=m * 60)).strftime("%H:%M:%S") for m in range(0, 24 * 60)] | |
| # Time options for schedule select entities (HH:MM:SS, across 24 h) | |
| # Use 5-minute steps to limit payload size for entity attributes while | |
| # still matching the core 5-minute prediction resolution. | |
| _GATEWAY_TIME_STEP_MINUTES = 5 | |
| _GATEWAY_BASE_TIME = datetime.datetime.strptime("00:00", "%H:%M") | |
| _GATEWAY_OPTIONS_TIME = [ | |
| (_GATEWAY_BASE_TIME + datetime.timedelta(seconds=m * 60)).strftime("%H:%M:%S") | |
| for m in range(0, 24 * 60, _GATEWAY_TIME_STEP_MINUTES) | |
| ] |
| self.log(f"Info: GatewayMQTT: Plan entries ({len(plan_entries)}): " + ", ".join(f"mode={e['mode']} {e['start_hour']:02d}:{e['start_minute']:02d}-{e['end_hour']:02d}:{e['end_minute']:02d}" for e in plan_entries)) | ||
|
|
||
| # Queue plan for async publishing (picked up by run() cycle) | ||
| if self._plan_changed(plan_entries): |
There was a problem hiding this comment.
This logs a full formatted list of plan entries at Info level on every plan execution. Since PredBat recalculates/executes plans frequently, this is likely to spam logs and increase IO. Recommend downgrading to Debug, or only logging when the plan actually changes (e.g. inside the _plan_changed() branch).
| self.log(f"Info: GatewayMQTT: Plan entries ({len(plan_entries)}): " + ", ".join(f"mode={e['mode']} {e['start_hour']:02d}:{e['start_minute']:02d}-{e['end_hour']:02d}:{e['end_minute']:02d}" for e in plan_entries)) | |
| # Queue plan for async publishing (picked up by run() cycle) | |
| if self._plan_changed(plan_entries): | |
| # Queue plan for async publishing (picked up by run() cycle) | |
| if self._plan_changed(plan_entries): | |
| self.log( | |
| "Info: GatewayMQTT: Plan entries ({}): {}".format( | |
| len(plan_entries), | |
| ", ".join( | |
| "mode={} {:02d}:{:02d}-{:02d}:{:02d}".format( | |
| e["mode"], | |
| e["start_hour"], | |
| e["start_minute"], | |
| e["end_hour"], | |
| e["end_minute"], | |
| ) | |
| for e in plan_entries | |
| ), | |
| ) | |
| ) |
| limit_kwh = charge_limits[i] if i < len(charge_limits or []) else soc_max | ||
| if limit_kwh <= 0: | ||
| continue | ||
| # XXX: If limit_khw == reserve then its a hold charge, need logic for this to be added |
There was a problem hiding this comment.
Spelling/typo in the comment: “limit_khw” should be “limit_kwh”, and “its” should be “it’s”. (Minor, but this comment is likely to be referenced when implementing the hold-charge logic.)
| # XXX: If limit_khw == reserve then its a hold charge, need logic for this to be added | |
| # XXX: If limit_kwh == reserve then it's a hold charge, need logic for this to be added |
| # idle_*_time should have one entry per inverter (1 in this case) | ||
| assert len(gw._args["idle_start_time"]) == 1 | ||
| assert len(gw._args["idle_end_time"]) == 1 | ||
| suffix = "em1234" # last 6 of "EM123456" lower-cased — actually "123456" |
There was a problem hiding this comment.
This local variable is unused and the inline comment is misleading (“em1234” is not the last 6 chars of EM123456). Please remove the unused assignment and keep the comment accurate to avoid confusion when maintaining these tests.
| suffix = "em1234" # last 6 of "EM123456" lower-cased — actually "123456" |
No description provided.