diff --git a/grobro/ha/client.py b/grobro/ha/client.py index 57091cc..3e5e7bb 100644 --- a/grobro/ha/client.py +++ b/grobro/ha/client.py @@ -184,7 +184,7 @@ def iter_command_registers(known_registers: GroBroRegisters): # ------------------- Client-Class ------------------- class Client: - on_command: Optional[Callable[[GrowattModbusFunctionSingle], None]] + on_command: Optional[Callable[[GrowattModbusFunctionSingle], None]] = None on_config_command: Optional[Callable[[str, int, str], None]] = None on_config_read: Optional[Callable[[str, int], None]] = None on_config_read_response: Callable[[str, int], None] | None = None @@ -221,7 +221,7 @@ def __init__(self, mqtt_config: model.MQTTConfig): self._client.connect(mqtt_config.host, mqtt_config.port, 60) # Subscriptions - for cmd_type in ["number", "button", "switch", "config"]: + for cmd_type in ["number", "time", "button", "switch", "select", "config"]: for action in ["set", "read"]: topic = f"{HA_BASE_TOPIC}/{cmd_type}/grobro/+/+/{action}" self._client.subscribe(topic) @@ -373,7 +373,7 @@ def __on_connect(self, client, userdata, flags, reason_code, properties): def __on_message(self, client, userdata, msg: mqtt.MQTTMessage): parts = msg.topic.removeprefix(f"{HA_BASE_TOPIC}/").split("/") - if len(parts) != 5 or parts[0] not in {"number", "button", "switch", "config"}: + if len(parts) != 5 or parts[0] not in {"number", "time", "button", "switch", "select", "config"}: return cmd_type, _, device_id, cmd_name, action = parts @@ -387,7 +387,7 @@ def __on_message(self, client, userdata, msg: mqtt.MQTTMessage): # Buttons if cmd_type == "button": if cmd_name == "read_all": - # Send all modbus reads first + # Send all modbus reads first for name, register in known_registers.holding_registers.items(): if name.startswith("slot"): try: @@ -395,62 +395,140 @@ def __on_message(self, client, userdata, msg: mqtt.MQTTMessage): continue except ValueError: continue + pos = register.growatt.position - self.on_command(make_modbus_command( - device_id, - GrowattModbusFunction.READ_SINGLE_REGISTER, - pos.register_no, - )) + self.on_command( + make_modbus_command( + device_id, + GrowattModbusFunction.READ_SINGLE_REGISTER, + pos.register_no, + ) + ) # Queue config reads if self.on_config_read: with self._config_read_lock: q = self._config_read_queues.setdefault(device_id, deque()) + for cfg in known_registers.config_registers.values(): q.append(cfg.growatt.register_no) # give the datalogger time to answer modbus reads Timer( - 3.0, # small delay is enough + 3.0, self.__kickoff_next_config_read, args=(device_id,), ).start() return - + if action == "read": - pos = known_registers.holding_registers[cmd_name].growatt.position - self.on_command(make_modbus_command( - device_id, GrowattModbusFunction.READ_SINGLE_REGISTER, pos.register_no - )) + reg = known_registers.holding_registers.get(cmd_name) + if not reg: + LOG.error( + "Unknown read command %s for device %s", + cmd_name, + device_id, + ) + return + + pos = reg.growatt.position + + self.on_command( + make_modbus_command( + device_id, + GrowattModbusFunction.READ_SINGLE_REGISTER, + pos.register_no, + ) + ) + + return + + # Number / Switch / Time / Select + if cmd_type in {"number", "switch", "time", "select"} and action == "set": + raw_value = msg.payload.decode().strip() + + reg = known_registers.holding_registers.get(cmd_name) + if not reg: + LOG.error( + "Unknown holding register %s for device %s", + cmd_name, + device_id, + ) return - # Number / Switch - if cmd_type in {"number", "switch"} and action == "set": - raw_value = msg.payload.decode() if cmd_type == "switch": parsed_value = 1 if raw_value.upper() == "ON" else 0 - elif "_start_time" in cmd_name or "_end_time" in cmd_name: - hour, minute = divmod(int(raw_value), 100) + + elif cmd_type == "time": + hour, minute = map(int, raw_value.split(":")[:2]) parsed_value = (hour * 256) + minute + + elif cmd_type == "select": + options = getattr( + reg.homeassistant, + "options", + None, + ) + + if options: + reverse_options = { + value: int(key) + for key, value in options.items() + } + + if raw_value not in reverse_options: + LOG.error( + "Unknown select value %s for %s", + raw_value, + cmd_name, + ) + return + + parsed_value = reverse_options[raw_value] + + else: + parsed_value = int(raw_value) + else: parsed_value = int(raw_value) - pos = known_registers.holding_registers[cmd_name].growatt.position - LOG.debug("Setting %s register %s to value %s", cmd_name, pos.register_no, parsed_value) + pos = reg.growatt.position + + LOG.debug( + "Setting %s register %s to value %s", + cmd_name, + pos.register_no, + parsed_value, + ) + + self.on_command( + make_modbus_command( + device_id, + GrowattModbusFunction.PRESET_SINGLE_REGISTER, + pos.register_no, + parsed_value, + ) + ) + + LOG.debug( + "Triggering read-after-write for Command %s register %s", + cmd_name, + pos.register_no, + ) - # write - self.on_command(make_modbus_command( - device_id, GrowattModbusFunction.PRESET_SINGLE_REGISTER, pos.register_no, parsed_value - )) - # read-after-write - LOG.debug("Triggering read-after-write for Command %s register %s", cmd_name, pos.register_no) - self.on_command(make_modbus_command( - device_id, GrowattModbusFunction.READ_SINGLE_REGISTER, pos.register_no - )) + self.on_command( + make_modbus_command( + device_id, + GrowattModbusFunction.READ_SINGLE_REGISTER, + pos.register_no, + ) + ) + + return # Config - if parts[0] == "config" and action == "set": + if cmd_type == "config" and action == "set": register_no = int(cmd_name) raw_value = msg.payload.decode().strip() @@ -554,6 +632,14 @@ def __publish_device_discovery(self, device_id: str, effective_max_bat: int | No unique_id = f"grobro_{device_id}_cmd_{entry['name']}" platform = ha.type + ha_data = ha.model_dump(exclude_none=True) + + # Home Assistant erwartet bei Select eine Liste, kein Dict + if platform == "select": + options = ha_data.get("options") + if isinstance(options, dict): + ha_data["options"] = list(options.values()) + payload["cmps"][unique_id] = { "platform": platform, "name": ha.name, @@ -566,9 +652,8 @@ def __publish_device_discovery(self, device_id: str, effective_max_bat: int | No f"{HA_BASE_TOPIC}/{entry['topic_root']}/grobro/" f"{device_id}/{entry['state_id']}/get" ), - **ha.model_dump(exclude_none=True), + **ha_data, } - # Config command: Restart Datalogger (Register 32 / Value 1) restart_uid = f"grobro_{device_id}_restart_datalogger" payload["cmps"][restart_uid] = { @@ -640,7 +725,7 @@ def __publish_device_discovery(self, device_id: str, effective_max_bat: int | No "unique_id": uid, "icon": "mdi:identifier", } - + # Combined firmware version (NOAH = 3 parts, NEXA = 4 parts) fw_version_parts = sorted( name @@ -665,7 +750,7 @@ def __publish_device_discovery(self, device_id: str, effective_max_bat: int | No "unique_id": firmware_unique_id, "icon": "mdi:information", } - + # Serial Number Entity serial_unique_id = f"grobro_{device_id}_serial" payload["cmps"][serial_unique_id] = { @@ -675,7 +760,7 @@ def __publish_device_discovery(self, device_id: str, effective_max_bat: int | No "unique_id": serial_unique_id, "icon": "mdi:identifier", } - + # Device Type Entity type_unique_id = f"grobro_{device_id}_type" payload["cmps"][type_unique_id] = { @@ -706,6 +791,7 @@ def __publish_device_discovery(self, device_id: str, effective_max_bat: int | No # trotzdem States aktualisieren self._client.publish(f"{HA_BASE_TOPIC}/grobro/{device_id}/serial", device_id, retain=True) self._client.publish(f"{HA_BASE_TOPIC}/grobro/{device_id}/type", get_device_type_name(device_id), retain=True) + self._client.publish(f"{HA_BASE_TOPIC}/grobro/{device_id}/sw_version", device_id, retain=True) return LOG.info("Publishing updated discovery for %s", device_id) @@ -860,4 +946,3 @@ def handle_config_read_response(self, device_id: str, register_no: int): # continue with next queued read self.__kickoff_next_config_read(device_id) - diff --git a/grobro/model/growatt_nexa_registers.json b/grobro/model/growatt_nexa_registers.json index 20d71ea..cfa2ad4 100644 --- a/grobro/model/growatt_nexa_registers.json +++ b/grobro/model/growatt_nexa_registers.json @@ -163,10 +163,7 @@ "homeassistant": { "publish": true, "name": "Slot 1 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -184,10 +181,7 @@ "homeassistant": { "publish": true, "name": "Slot 1 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -263,10 +257,7 @@ "homeassistant": { "publish": true, "name": "Slot 2 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -284,10 +275,7 @@ "homeassistant": { "publish": true, "name": "Slot 2 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -363,10 +351,7 @@ "homeassistant": { "publish": true, "name": "Slot 3 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -384,10 +369,7 @@ "homeassistant": { "publish": true, "name": "Slot 3 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -463,10 +445,7 @@ "homeassistant": { "publish": true, "name": "Slot 4 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -484,10 +463,7 @@ "homeassistant": { "publish": true, "name": "Slot 4 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -563,10 +539,7 @@ "homeassistant": { "publish": true, "name": "Slot 5 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -584,10 +557,7 @@ "homeassistant": { "publish": true, "name": "Slot 5 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -663,10 +633,7 @@ "homeassistant": { "publish": true, "name": "Slot 6 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -684,10 +651,7 @@ "homeassistant": { "publish": true, "name": "Slot 6 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -763,10 +727,7 @@ "homeassistant": { "publish": true, "name": "Slot 7 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -784,10 +745,7 @@ "homeassistant": { "publish": true, "name": "Slot 7 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -863,10 +821,7 @@ "homeassistant": { "publish": true, "name": "Slot 8 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -884,10 +839,7 @@ "homeassistant": { "publish": true, "name": "Slot 8 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -963,10 +915,7 @@ "homeassistant": { "publish": true, "name": "Slot 9 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -984,10 +933,7 @@ "homeassistant": { "publish": true, "name": "Slot 9 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, diff --git a/grobro/model/growatt_noah_registers.json b/grobro/model/growatt_noah_registers.json index d8e763a..e7d8452 100644 --- a/grobro/model/growatt_noah_registers.json +++ b/grobro/model/growatt_noah_registers.json @@ -76,10 +76,7 @@ "homeassistant": { "publish": true, "name": "Slot 1 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -97,10 +94,7 @@ "homeassistant": { "publish": true, "name": "Slot 1 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -112,16 +106,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 1 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot1_power": { @@ -176,10 +180,7 @@ "homeassistant": { "publish": true, "name": "Slot 2 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -197,10 +198,7 @@ "homeassistant": { "publish": true, "name": "Slot 2 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -212,16 +210,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 2 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot2_power": { @@ -276,10 +284,7 @@ "homeassistant": { "publish": true, "name": "Slot 3 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -297,10 +302,7 @@ "homeassistant": { "publish": true, "name": "Slot 3 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -312,16 +314,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 3 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot3_power": { @@ -376,10 +388,7 @@ "homeassistant": { "publish": true, "name": "Slot 4 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -397,10 +406,7 @@ "homeassistant": { "publish": true, "name": "Slot 4 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -412,16 +418,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 4 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot4_power": { @@ -476,10 +492,7 @@ "homeassistant": { "publish": true, "name": "Slot 5 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -497,10 +510,7 @@ "homeassistant": { "publish": true, "name": "Slot 5 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -512,16 +522,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 5 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot5_power": { @@ -576,10 +596,7 @@ "homeassistant": { "publish": true, "name": "Slot 6 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -597,10 +614,7 @@ "homeassistant": { "publish": true, "name": "Slot 6 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -612,16 +626,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 6 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot6_power": { @@ -676,10 +700,7 @@ "homeassistant": { "publish": true, "name": "Slot 7 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -697,10 +718,7 @@ "homeassistant": { "publish": true, "name": "Slot 7 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -712,16 +730,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 7 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot7_power": { @@ -776,10 +804,7 @@ "homeassistant": { "publish": true, "name": "Slot 8 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -797,10 +822,7 @@ "homeassistant": { "publish": true, "name": "Slot 8 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -812,16 +834,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 8 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot8_power": { @@ -876,10 +908,7 @@ "homeassistant": { "publish": true, "name": "Slot 9 Start Time", - "type": "number", - "min": 0, - "max": 2358, - "step": 1, + "type": "time", "icon": "mdi:clock-start" } }, @@ -897,10 +926,7 @@ "homeassistant": { "publish": true, "name": "Slot 9 End Time", - "type": "number", - "min": 0, - "max": 2359, - "step": 1, + "type": "time", "icon": "mdi:clock-end" } }, @@ -912,16 +938,26 @@ "size": 2 }, "data": { - "data_type": "INT" + "data_type": "ENUM", + "enum_options": { + "enum_type": "INT_MAP", + "values": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } + } } }, "homeassistant": { "publish": true, "name": "Slot 9 Mode", - "type": "number", - "min": 0, - "max": 2, - "step": 1 + "type": "select", + "options": { + "0": "Load First", + "1": "Battery First", + "2": "Smart Mode" + } } }, "slot9_power": { diff --git a/grobro/model/growatt_registers.py b/grobro/model/growatt_registers.py index 4a32d24..59460f6 100644 --- a/grobro/model/growatt_registers.py +++ b/grobro/model/growatt_registers.py @@ -55,22 +55,21 @@ def parse(self, data_raw: bytes): return round(value, 3) elif self.data_type == GrowattRegisterDataTypes.TIME_HHMM: value = struct.unpack(unpack_type, data_raw)[0] - h = value // 256 - m = value % 256 - return (h * 100) + m + hour = (value >> 8) & 0xFF + minute = value & 0xFF + return f"{hour:02d}:{minute:02d}" elif self.data_type in [GrowattRegisterDataTypes.INT, GrowattRegisterDataTypes.SIGNED_INT]: value = struct.unpack(unpack_type, data_raw)[0] return value elif self.data_type == GrowattRegisterDataTypes.ENUM: opts = self.enum_options value = struct.unpack(unpack_type, data_raw)[0] + if opts.enum_type == GrowattRegisterEnumTypes.BITFIELD: return None # TODO: implement + elif opts.enum_type == GrowattRegisterEnumTypes.INT_MAP: - enum_value = opts.values.get(int(value), None) - if not enum_value: - return None - return value + return opts.values.get(int(value), "unknown") class GrowattRegisterPosition(BaseModel): @@ -95,7 +94,8 @@ class HomeAssistantHoldingRegister(BaseModel): device_class: Optional[str] = None unit_of_measurement: Optional[str] = None icon: Optional[str] = None - + options: Optional[dict[str, str]] = None + model_config = ConfigDict(extra="forbid")