diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index d783cc3de..9850f37ab 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -87,6 +87,7 @@ def _serial_suffix(serial): GATEWAY_ATTRIBUTE_TABLE = { # Binary sensors "gateway_online": {"friendly_name": "Gateway Online", "device_class": "connectivity"}, + "gateway_read_only": {"friendly_name": "Gateway Read-Only (enforced)", "icon": "mdi:lock"}, # Timestamps "inverter_time": {"friendly_name": "Inverter Time", "icon": "mdi:clock", "device_class": "timestamp"}, # Battery state @@ -293,6 +294,9 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt # Monotonic-ish timestamp of the last set_read_only send, used to force a # periodic re-send so the gateway re-syncs even if a command was missed self._last_read_only_sent_time = 0 + # Latch so a gateway whose reported read-only state disagrees with what PredBat + # requested is logged once per transition, not on every status cycle + self._read_only_mismatch_logged = False # Set once the first MQTT connection attempt has completed (success or failure) self._first_connection_attempted = False @@ -791,13 +795,27 @@ def _inject_entities(self, status): device_id = status.device_id firmware = status.firmware + # Read-only as the gateway actually enforces it. Absent on firmware predating + # the field, which is UNKNOWN and not False — publishing False there would claim + # the gateway is controllable when nothing has reported either way. + read_only = status.read_only if status.HasField("read_only") else None + self.dashboard_item( f"binary_sensor.{self.prefix}_gateway_online", True, - attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware}, + attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware, "read_only": read_only}, app="gateway", ) + if read_only is not None: + self.dashboard_item( + f"binary_sensor.{self.prefix}_gateway_read_only", + read_only, + attributes=GATEWAY_ATTRIBUTE_TABLE.get("gateway_read_only", {}), + app="gateway", + ) + self._check_read_only_mismatch(read_only) + # Inverter time from gateway timestamp — write it under the suffix PredBat # actually reads (the control target), not the primary's, or the bound # inverter_time arg is never updated and silently freezes. @@ -1592,6 +1610,27 @@ async def _check_read_only_state(self): self._last_read_only_sent_time = now self.log(f"Info: GatewayMQTT: set_read_only command sent (read_only={read_only})") + def _check_read_only_mismatch(self, reported): + """Warn when the gateway's enforced read-only state differs from what PredBat asked for. + + Observe-only: called from the telemetry path with the value the gateway reports in + its status, and never sends a command. _last_read_only holds the value of the last + set_read_only command PredBat published, so the two are directly comparable; it is + None before the first send (and after a disconnect forces a re-send), in which case + there is nothing to compare against yet. + + The status arrives every cycle, so the warning is latched and logged once per + transition into disagreement, and the latch is cleared as soon as the two agree + again — otherwise a stuck gateway would fill the log. + """ + requested = self._last_read_only + if requested is None or reported == requested: + self._read_only_mismatch_logged = False + return + if not self._read_only_mismatch_logged: + self.log(f"Warn: GatewayMQTT: gateway reports read_only={reported} but PredBat requested {requested}") + self._read_only_mismatch_logged = True + async def _check_inverter_resets(self): """Send inverter_reset for each configured inverter not yet reset in control mode. diff --git a/apps/predbat/gateway_status.proto b/apps/predbat/gateway_status.proto index 751bf3960..d1497f5ea 100644 --- a/apps/predbat/gateway_status.proto +++ b/apps/predbat/gateway_status.proto @@ -175,6 +175,15 @@ message GatewayStatus { uint32 heap_internal_free = 15; // heap_caps_get_free_size(INTERNAL | 8BIT) uint32 heap_internal_largest = 16; // largest contiguous block — the leading indicator uint32 heap_internal_min_free = 17; // low-water mark since boot + + // Hub-side read-only flag (g_read_only), as actually enforced: write-class + // commands rejected and autonomous mode disabled. PredBat mirrors its own + // read-only mode here via the set_read_only command, but that is a two-hop + // mirror with no other feedback — the ack carries no value, and a hub that was + // offline at the transition only catches up on the 30-minute resend. Reporting + // the enforced state on every status makes "is this hub actually read-only?" + // answerable from the cloud instead of inferred. Absent on older firmware. + optional bool read_only = 18; } // Execution plan sent from PredBat cloud to gateway diff --git a/apps/predbat/gateway_status_pb2.py b/apps/predbat/gateway_status_pb2.py index 4e74f50df..dd4760331 100644 --- a/apps/predbat/gateway_status_pb2.py +++ b/apps/predbat/gateway_status_pb2.py @@ -17,7 +17,7 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x14gateway_status.proto\x12\x07predbat"\xe5\x01\n\rBatteryStatus\x12\x13\n\x0bsoc_percent\x18\x01 \x01(\r\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x11\n\tcurrent_a\x18\x03 \x01(\x02\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x15\n\rtemperature_c\x18\x05 \x01(\x02\x12\x13\n\x0bsoh_percent\x18\x06 \x01(\r\x12\x13\n\x0b\x63ycle_count\x18\x07 \x01(\r\x12\x13\n\x0b\x63\x61pacity_wh\x18\x08 \x01(\x05\x12\x12\n\nrate_max_w\x18\t \x01(\r\x12\x1e\n\x16\x64\x65pth_of_discharge_pct\x18\n \x01(\r"\x1b\n\x08PvStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"F\n\nGridStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x14\n\x0c\x66requency_hz\x18\x03 \x01(\x02"\x1d\n\nLoadStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"Q\n\x0cInverterData\x12\x16\n\x0e\x61\x63tive_power_w\x18\x01 \x01(\x05\x12\x15\n\rtemperature_c\x18\x02 \x01(\x02\x12\x12\n\nrate_max_w\x18\x03 \x01(\r"\x83\x02\n\rControlStatus\x12\x16\n\x0e\x63harge_enabled\x18\x02 \x01(\x08\x12\x19\n\x11\x64ischarge_enabled\x18\x03 \x01(\x08\x12\x15\n\rcharge_rate_w\x18\x04 \x01(\r\x12\x18\n\x10\x64ischarge_rate_w\x18\x05 \x01(\r\x12\x13\n\x0breserve_soc\x18\x06 \x01(\r\x12\x12\n\ntarget_soc\x18\x07 \x01(\r\x12\x15\n\rforce_power_w\x18\x08 \x01(\r\x12\x17\n\x0f\x63ommand_expires\x18\t \x01(\r\x12\x16\n\x0e\x65xport_limit_w\x18\n \x01(\r\x12\x17\n\x0freserve_soc_max\x18\x0b \x01(\rJ\x04\x08\x01\x10\x02"j\n\x0eScheduleStatus\x12\x14\n\x0c\x63harge_start\x18\x01 \x01(\r\x12\x12\n\ncharge_end\x18\x02 \x01(\r\x12\x17\n\x0f\x64ischarge_start\x18\x03 \x01(\r\x12\x15\n\rdischarge_end\x18\x04 \x01(\r"^\n\x0e\x45msSubInverter\x12\x0b\n\x03soc\x18\x01 \x01(\r\x12\x11\n\tbattery_w\x18\x02 \x01(\x05\x12\x0c\n\x04pv_w\x18\x03 \x01(\x05\x12\x0e\n\x06grid_w\x18\x04 \x01(\x05\x12\x0e\n\x06temp_c\x18\x05 \x01(\x02"\xd8\x01\n\tEmsStatus\x12\x15\n\rnum_inverters\x18\x01 \x01(\r\x12\x11\n\ttotal_soc\x18\x02 \x01(\r\x12\x16\n\x0etotal_charge_w\x18\x03 \x01(\x05\x12\x19\n\x11total_discharge_w\x18\x04 \x01(\x05\x12\x14\n\x0ctotal_grid_w\x18\x05 \x01(\x05\x12\x12\n\ntotal_pv_w\x18\x06 \x01(\x05\x12\x14\n\x0ctotal_load_w\x18\x07 \x01(\x05\x12.\n\rsub_inverters\x18\x08 \x03(\x0b\x32\x17.predbat.EmsSubInverter"\xc4\x01\n\x0e\x45nergyCounters\x12\x13\n\x0bpv_today_wh\x18\x01 \x01(\r\x12\x1c\n\x14grid_import_today_wh\x18\x02 \x01(\r\x12\x1c\n\x14grid_export_today_wh\x18\x03 \x01(\r\x12\x1f\n\x17\x62\x61ttery_charge_today_wh\x18\x04 \x01(\r\x12"\n\x1a\x62\x61ttery_discharge_today_wh\x18\x05 \x01(\r\x12\x1c\n\x14\x63onsumption_today_wh\x18\x06 \x01(\r"\x90\x04\n\rInverterEntry\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.predbat.InverterType\x12\x0e\n\x06serial\x18\x02 \x01(\t\x12\n\n\x02ip\x18\x03 \x01(\t\x12\x11\n\tconnected\x18\x04 \x01(\x08\x12\x0e\n\x06\x61\x63tive\x18\x05 \x01(\x08\x12\'\n\x07\x62\x61ttery\x18\x06 \x01(\x0b\x32\x16.predbat.BatteryStatus\x12\x1d\n\x02pv\x18\x07 \x01(\x0b\x32\x11.predbat.PvStatus\x12!\n\x04grid\x18\x08 \x01(\x0b\x32\x13.predbat.GridStatus\x12!\n\x04load\x18\t \x01(\x0b\x32\x13.predbat.LoadStatus\x12\'\n\x08inverter\x18\n \x01(\x0b\x32\x15.predbat.InverterData\x12\'\n\x07\x63ontrol\x18\x0b \x01(\x0b\x32\x16.predbat.ControlStatus\x12)\n\x08schedule\x18\x0c \x01(\x0b\x32\x17.predbat.ScheduleStatus\x12\x1f\n\x03\x65ms\x18\r \x01(\x0b\x32\x12.predbat.EmsStatus\x12\'\n\x06\x65nergy\x18\x0e \x01(\x0b\x32\x17.predbat.EnergyCounters\x12\x0f\n\x07managed\x18\x0f \x01(\x08\x12\x0f\n\x07primary\x18\x10 \x01(\x08\x12\x15\n\rdongle_serial\x18\x11 \x01(\t\x12\r\n\x05model\x18\x12 \x01(\t"\x8b\x02\n\tEvCharger\x12\x11\n\tconnected\x18\x01 \x01(\x08\x12\x16\n\x0esession_active\x18\x02 \x01(\x08\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x19\n\x11session_energy_wh\x18\x05 \x01(\r\x12\x14\n\x0csession_secs\x18\x06 \x01(\r\x12\x17\n\x0f\x63urrent_limit_a\x18\x07 \x01(\r\x12\x13\n\x0bsoc_percent\x18\x08 \x01(\r\x12\x17\n\x0f\x63harge_point_id\x18\t \x01(\t\x12\x15\n\rmax_current_a\x18\n \x01(\r\x12\x11\n\tvoltage_v\x18\x0b \x01(\r\x12\x10\n\x08\x65\x63o_mode\x18\x0c \x01(\t"\xcb\x03\n\rGatewayStatus\x12\x11\n\tdevice_id\x18\x01 \x01(\t\x12\x14\n\x0c\x64ongle_count\x18\x02 \x01(\r\x12\x10\n\x08\x66irmware\x18\x03 \x01(\t\x12\x11\n\ttimestamp\x18\x04 \x01(\r\x12\x16\n\x0eschema_version\x18\x05 \x01(\r\x12)\n\tinverters\x18\x06 \x03(\x0b\x32\x16.predbat.InverterEntry\x12\x16\n\x0euptime_seconds\x18\x07 \x01(\r\x12\x15\n\rwifi_rssi_dbm\x18\x08 \x01(\x05\x12\x17\n\x0f\x66ree_heap_bytes\x18\t \x01(\r\x12\x11\n\tlog_level\x18\n \x01(\r\x12\x0f\n\x07wifi_ip\x18\x0b \x01(\t\x12\x17\n\nev_control\x18\x0c \x01(\x08H\x00\x88\x01\x01\x12\'\n\x0b\x65v_chargers\x18\r \x03(\x0b\x32\x12.predbat.EvCharger\x12\x11\n\twifi_ssid\x18\x0e \x01(\t\x12\x1a\n\x12heap_internal_free\x18\x0f \x01(\r\x12\x1d\n\x15heap_internal_largest\x18\x10 \x01(\r\x12\x1e\n\x16heap_internal_min_free\x18\x11 \x01(\rB\r\n\x0b_ev_control"\xc9\x01\n\tPlanEntry\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x12\n\nstart_hour\x18\x02 \x01(\r\x12\x14\n\x0cstart_minute\x18\x03 \x01(\r\x12\x10\n\x08\x65nd_hour\x18\x04 \x01(\r\x12\x12\n\nend_minute\x18\x05 \x01(\r\x12\x0c\n\x04mode\x18\x06 \x01(\r\x12\x0f\n\x07power_w\x18\x07 \x01(\r\x12\x12\n\ntarget_soc\x18\x08 \x01(\r\x12\x14\n\x0c\x64\x61ys_of_week\x18\t \x01(\r\x12\x12\n\nuse_native\x18\n \x01(\x08"o\n\rExecutionPlan\x12\x11\n\ttimestamp\x18\x01 \x01(\r\x12\x14\n\x0cplan_version\x18\x02 \x01(\r\x12\x10\n\x08timezone\x18\x03 \x01(\t\x12#\n\x07\x65ntries\x18\x04 \x03(\x0b\x32\x12.predbat.PlanEntry*\xf2\x02\n\x0cInverterType\x12\x19\n\x15INVERTER_TYPE_UNKNOWN\x10\x00\x12\x1e\n\x1aINVERTER_TYPE_SOLIS_HYBRID\x10\x01\x12\x1a\n\x16INVERTER_TYPE_SOLIS_AC\x10\x02\x12\x1a\n\x16INVERTER_TYPE_SOFAR_G3\x10\x03\x12\x1d\n\x19INVERTER_TYPE_GROWATT_SPH\x10\x04\x12\x1e\n\x1aINVERTER_TYPE_DEYE_SUNSYNK\x10\x05\x12\x1b\n\x17INVERTER_TYPE_GIVENERGY\x10\x06\x12\x1f\n\x1bINVERTER_TYPE_GIVENERGY_EMS\x10\x07\x12#\n\x1fINVERTER_TYPE_GIVENERGY_GATEWAY\x10\x08\x12\x1b\n\x17INVERTER_TYPE_ALPHA_ESS\x10\t\x12\x16\n\x12INVERTER_TYPE_DEMO\x10\n\x12\x18\n\x14INVERTER_TYPE_CUSTOM\x10\x0b\x62\x06proto3' + b'\n\x14gateway_status.proto\x12\x07predbat"\xe5\x01\n\rBatteryStatus\x12\x13\n\x0bsoc_percent\x18\x01 \x01(\r\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x11\n\tcurrent_a\x18\x03 \x01(\x02\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x15\n\rtemperature_c\x18\x05 \x01(\x02\x12\x13\n\x0bsoh_percent\x18\x06 \x01(\r\x12\x13\n\x0b\x63ycle_count\x18\x07 \x01(\r\x12\x13\n\x0b\x63\x61pacity_wh\x18\x08 \x01(\x05\x12\x12\n\nrate_max_w\x18\t \x01(\r\x12\x1e\n\x16\x64\x65pth_of_discharge_pct\x18\n \x01(\r"\x1b\n\x08PvStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"F\n\nGridStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x14\n\x0c\x66requency_hz\x18\x03 \x01(\x02"\x1d\n\nLoadStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"Q\n\x0cInverterData\x12\x16\n\x0e\x61\x63tive_power_w\x18\x01 \x01(\x05\x12\x15\n\rtemperature_c\x18\x02 \x01(\x02\x12\x12\n\nrate_max_w\x18\x03 \x01(\r"\x83\x02\n\rControlStatus\x12\x16\n\x0e\x63harge_enabled\x18\x02 \x01(\x08\x12\x19\n\x11\x64ischarge_enabled\x18\x03 \x01(\x08\x12\x15\n\rcharge_rate_w\x18\x04 \x01(\r\x12\x18\n\x10\x64ischarge_rate_w\x18\x05 \x01(\r\x12\x13\n\x0breserve_soc\x18\x06 \x01(\r\x12\x12\n\ntarget_soc\x18\x07 \x01(\r\x12\x15\n\rforce_power_w\x18\x08 \x01(\r\x12\x17\n\x0f\x63ommand_expires\x18\t \x01(\r\x12\x16\n\x0e\x65xport_limit_w\x18\n \x01(\r\x12\x17\n\x0freserve_soc_max\x18\x0b \x01(\rJ\x04\x08\x01\x10\x02"j\n\x0eScheduleStatus\x12\x14\n\x0c\x63harge_start\x18\x01 \x01(\r\x12\x12\n\ncharge_end\x18\x02 \x01(\r\x12\x17\n\x0f\x64ischarge_start\x18\x03 \x01(\r\x12\x15\n\rdischarge_end\x18\x04 \x01(\r"^\n\x0e\x45msSubInverter\x12\x0b\n\x03soc\x18\x01 \x01(\r\x12\x11\n\tbattery_w\x18\x02 \x01(\x05\x12\x0c\n\x04pv_w\x18\x03 \x01(\x05\x12\x0e\n\x06grid_w\x18\x04 \x01(\x05\x12\x0e\n\x06temp_c\x18\x05 \x01(\x02"\xd8\x01\n\tEmsStatus\x12\x15\n\rnum_inverters\x18\x01 \x01(\r\x12\x11\n\ttotal_soc\x18\x02 \x01(\r\x12\x16\n\x0etotal_charge_w\x18\x03 \x01(\x05\x12\x19\n\x11total_discharge_w\x18\x04 \x01(\x05\x12\x14\n\x0ctotal_grid_w\x18\x05 \x01(\x05\x12\x12\n\ntotal_pv_w\x18\x06 \x01(\x05\x12\x14\n\x0ctotal_load_w\x18\x07 \x01(\x05\x12.\n\rsub_inverters\x18\x08 \x03(\x0b\x32\x17.predbat.EmsSubInverter"\xc4\x01\n\x0e\x45nergyCounters\x12\x13\n\x0bpv_today_wh\x18\x01 \x01(\r\x12\x1c\n\x14grid_import_today_wh\x18\x02 \x01(\r\x12\x1c\n\x14grid_export_today_wh\x18\x03 \x01(\r\x12\x1f\n\x17\x62\x61ttery_charge_today_wh\x18\x04 \x01(\r\x12"\n\x1a\x62\x61ttery_discharge_today_wh\x18\x05 \x01(\r\x12\x1c\n\x14\x63onsumption_today_wh\x18\x06 \x01(\r"\x90\x04\n\rInverterEntry\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.predbat.InverterType\x12\x0e\n\x06serial\x18\x02 \x01(\t\x12\n\n\x02ip\x18\x03 \x01(\t\x12\x11\n\tconnected\x18\x04 \x01(\x08\x12\x0e\n\x06\x61\x63tive\x18\x05 \x01(\x08\x12\'\n\x07\x62\x61ttery\x18\x06 \x01(\x0b\x32\x16.predbat.BatteryStatus\x12\x1d\n\x02pv\x18\x07 \x01(\x0b\x32\x11.predbat.PvStatus\x12!\n\x04grid\x18\x08 \x01(\x0b\x32\x13.predbat.GridStatus\x12!\n\x04load\x18\t \x01(\x0b\x32\x13.predbat.LoadStatus\x12\'\n\x08inverter\x18\n \x01(\x0b\x32\x15.predbat.InverterData\x12\'\n\x07\x63ontrol\x18\x0b \x01(\x0b\x32\x16.predbat.ControlStatus\x12)\n\x08schedule\x18\x0c \x01(\x0b\x32\x17.predbat.ScheduleStatus\x12\x1f\n\x03\x65ms\x18\r \x01(\x0b\x32\x12.predbat.EmsStatus\x12\'\n\x06\x65nergy\x18\x0e \x01(\x0b\x32\x17.predbat.EnergyCounters\x12\x0f\n\x07managed\x18\x0f \x01(\x08\x12\x0f\n\x07primary\x18\x10 \x01(\x08\x12\x15\n\rdongle_serial\x18\x11 \x01(\t\x12\r\n\x05model\x18\x12 \x01(\t"\x8b\x02\n\tEvCharger\x12\x11\n\tconnected\x18\x01 \x01(\x08\x12\x16\n\x0esession_active\x18\x02 \x01(\x08\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x19\n\x11session_energy_wh\x18\x05 \x01(\r\x12\x14\n\x0csession_secs\x18\x06 \x01(\r\x12\x17\n\x0f\x63urrent_limit_a\x18\x07 \x01(\r\x12\x13\n\x0bsoc_percent\x18\x08 \x01(\r\x12\x17\n\x0f\x63harge_point_id\x18\t \x01(\t\x12\x15\n\rmax_current_a\x18\n \x01(\r\x12\x11\n\tvoltage_v\x18\x0b \x01(\r\x12\x10\n\x08\x65\x63o_mode\x18\x0c \x01(\t"\xf1\x03\n\rGatewayStatus\x12\x11\n\tdevice_id\x18\x01 \x01(\t\x12\x14\n\x0c\x64ongle_count\x18\x02 \x01(\r\x12\x10\n\x08\x66irmware\x18\x03 \x01(\t\x12\x11\n\ttimestamp\x18\x04 \x01(\r\x12\x16\n\x0eschema_version\x18\x05 \x01(\r\x12)\n\tinverters\x18\x06 \x03(\x0b\x32\x16.predbat.InverterEntry\x12\x16\n\x0euptime_seconds\x18\x07 \x01(\r\x12\x15\n\rwifi_rssi_dbm\x18\x08 \x01(\x05\x12\x17\n\x0f\x66ree_heap_bytes\x18\t \x01(\r\x12\x11\n\tlog_level\x18\n \x01(\r\x12\x0f\n\x07wifi_ip\x18\x0b \x01(\t\x12\x17\n\nev_control\x18\x0c \x01(\x08H\x00\x88\x01\x01\x12\'\n\x0b\x65v_chargers\x18\r \x03(\x0b\x32\x12.predbat.EvCharger\x12\x11\n\twifi_ssid\x18\x0e \x01(\t\x12\x1a\n\x12heap_internal_free\x18\x0f \x01(\r\x12\x1d\n\x15heap_internal_largest\x18\x10 \x01(\r\x12\x1e\n\x16heap_internal_min_free\x18\x11 \x01(\r\x12\x16\n\tread_only\x18\x12 \x01(\x08H\x01\x88\x01\x01\x42\r\n\x0b_ev_controlB\x0c\n\n_read_only"\xc9\x01\n\tPlanEntry\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x12\n\nstart_hour\x18\x02 \x01(\r\x12\x14\n\x0cstart_minute\x18\x03 \x01(\r\x12\x10\n\x08\x65nd_hour\x18\x04 \x01(\r\x12\x12\n\nend_minute\x18\x05 \x01(\r\x12\x0c\n\x04mode\x18\x06 \x01(\r\x12\x0f\n\x07power_w\x18\x07 \x01(\r\x12\x12\n\ntarget_soc\x18\x08 \x01(\r\x12\x14\n\x0c\x64\x61ys_of_week\x18\t \x01(\r\x12\x12\n\nuse_native\x18\n \x01(\x08"o\n\rExecutionPlan\x12\x11\n\ttimestamp\x18\x01 \x01(\r\x12\x14\n\x0cplan_version\x18\x02 \x01(\r\x12\x10\n\x08timezone\x18\x03 \x01(\t\x12#\n\x07\x65ntries\x18\x04 \x03(\x0b\x32\x12.predbat.PlanEntry*\xf2\x02\n\x0cInverterType\x12\x19\n\x15INVERTER_TYPE_UNKNOWN\x10\x00\x12\x1e\n\x1aINVERTER_TYPE_SOLIS_HYBRID\x10\x01\x12\x1a\n\x16INVERTER_TYPE_SOLIS_AC\x10\x02\x12\x1a\n\x16INVERTER_TYPE_SOFAR_G3\x10\x03\x12\x1d\n\x19INVERTER_TYPE_GROWATT_SPH\x10\x04\x12\x1e\n\x1aINVERTER_TYPE_DEYE_SUNSYNK\x10\x05\x12\x1b\n\x17INVERTER_TYPE_GIVENERGY\x10\x06\x12\x1f\n\x1bINVERTER_TYPE_GIVENERGY_EMS\x10\x07\x12#\n\x1fINVERTER_TYPE_GIVENERGY_GATEWAY\x10\x08\x12\x1b\n\x17INVERTER_TYPE_ALPHA_ESS\x10\t\x12\x16\n\x12INVERTER_TYPE_DEMO\x10\n\x12\x18\n\x14INVERTER_TYPE_CUSTOM\x10\x0b\x62\x06proto3' ) _globals = globals() @@ -25,8 +25,8 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "gateway_status_pb2", _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals["_INVERTERTYPE"]._serialized_start = 2945 - _globals["_INVERTERTYPE"]._serialized_end = 3315 + _globals["_INVERTERTYPE"]._serialized_start = 2983 + _globals["_INVERTERTYPE"]._serialized_end = 3353 _globals["_BATTERYSTATUS"]._serialized_start = 34 _globals["_BATTERYSTATUS"]._serialized_end = 263 _globals["_PVSTATUS"]._serialized_start = 265 @@ -52,9 +52,9 @@ _globals["_EVCHARGER"]._serialized_start = 1896 _globals["_EVCHARGER"]._serialized_end = 2163 _globals["_GATEWAYSTATUS"]._serialized_start = 2166 - _globals["_GATEWAYSTATUS"]._serialized_end = 2625 - _globals["_PLANENTRY"]._serialized_start = 2628 - _globals["_PLANENTRY"]._serialized_end = 2829 - _globals["_EXECUTIONPLAN"]._serialized_start = 2831 - _globals["_EXECUTIONPLAN"]._serialized_end = 2942 + _globals["_GATEWAYSTATUS"]._serialized_end = 2663 + _globals["_PLANENTRY"]._serialized_start = 2666 + _globals["_PLANENTRY"]._serialized_end = 2867 + _globals["_EXECUTIONPLAN"]._serialized_start = 2869 + _globals["_EXECUTIONPLAN"]._serialized_end = 2980 # @@protoc_insertion_point(module_scope) diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 6d7d5d8e5..ee0170d8b 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -294,6 +294,8 @@ def _make_gateway(self): gw.args = {} gw.local_tz = pytz.timezone("Europe/London") gw._suffix_to_serial = {} # set by automatic_config; empty = nothing bound yet + gw._last_read_only = None # no set_read_only command sent yet + gw._read_only_mismatch_logged = False gw._dashboard_calls = {} # entity_id → (state, attributes) def capture_dashboard(entity_id, state=None, attributes=None, app=None): @@ -374,6 +376,87 @@ def test_gateway_online_entity(self): for k, v in GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}).items(): assert attrs[k] == v + def test_gateway_read_only_entity_true(self): + """A gateway reporting read_only=true publishes the binary sensor True with table attributes.""" + from gateway import GATEWAY_ATTRIBUTE_TABLE + + gw = self._make_gateway() + status = self._make_status() + status.read_only = True + gw._inject_entities(status) + + entity = "binary_sensor.predbat_gateway_read_only" + assert entity in gw._dashboard_calls + state, attrs = gw._dashboard_calls[entity] + assert state is True + for k, v in GATEWAY_ATTRIBUTE_TABLE.get("gateway_read_only", {}).items(): + assert attrs[k] == v + # The online sensor carries the same value as an attribute + _, online_attrs = gw._dashboard_calls["binary_sensor.predbat_gateway_online"] + assert online_attrs["read_only"] is True + + def test_gateway_read_only_entity_false(self): + """read_only present and false publishes False, not an absent entity.""" + gw = self._make_gateway() + status = self._make_status() + status.read_only = False + gw._inject_entities(status) + + state, _ = gw._dashboard_calls["binary_sensor.predbat_gateway_read_only"] + assert state is False + _, online_attrs = gw._dashboard_calls["binary_sensor.predbat_gateway_online"] + assert online_attrs["read_only"] is False + + def test_gateway_read_only_absent_is_unknown(self): + """Firmware predating the field reports nothing: no entity, and the attribute is None.""" + gw = self._make_gateway() + status = self._make_status() + assert not status.HasField("read_only") + gw._inject_entities(status) + + assert "binary_sensor.predbat_gateway_read_only" not in gw._dashboard_calls + _, online_attrs = gw._dashboard_calls["binary_sensor.predbat_gateway_online"] + assert online_attrs["read_only"] is None + + def test_gateway_read_only_mismatch_warns_once(self): + """A gateway disagreeing with the last set_read_only warns once, and agreement clears the latch.""" + gw = self._make_gateway() + gw._last_read_only = True # PredBat asked for read-only + + status = self._make_status() + status.read_only = False # gateway is not enforcing it + gw._inject_entities(status) + + warnings = [c[0][0] for c in gw.log.call_args_list if "read_only" in c[0][0]] + assert len(warnings) == 1, warnings + assert warnings[0].startswith("Warn:") + assert "requested True" in warnings[0] + assert gw._read_only_mismatch_logged is True + + # A second identical status must not repeat the warning + gw._inject_entities(status) + warnings = [c[0][0] for c in gw.log.call_args_list if "read_only" in c[0][0]] + assert len(warnings) == 1, warnings + + # Once the gateway agrees the latch clears, so a later mismatch warns again + status.read_only = True + gw._inject_entities(status) + assert gw._read_only_mismatch_logged is False + status.read_only = False + gw._inject_entities(status) + warnings = [c[0][0] for c in gw.log.call_args_list if "read_only" in c[0][0]] + assert len(warnings) == 2, warnings + + def test_gateway_read_only_no_mismatch_before_first_send(self): + """Nothing sent yet (_last_read_only None) means nothing to compare, so no warning.""" + gw = self._make_gateway() + status = self._make_status() + status.read_only = True + gw._inject_entities(status) + + warnings = [c[0][0] for c in gw.log.call_args_list if "read_only" in c[0][0]] + assert warnings == [] + def test_inverter_time_sensor(self): """Inverter time sensor is published using the primary inverter serial suffix.""" gw = self._make_gateway()