From 492908a33d92f0fb6b6c2ce66f3b611cd33dbe87 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Thu, 18 Jun 2026 09:51:22 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(lattice):=20gateway=20producer=20?= =?UTF-8?q?=E2=80=94=20fragment,=20device-type,=20control=20+=20serial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayMQTT.lattice_fragment (local-Modbus inverters, charge/discharge controllable), _lattice_device_type (real inverter type from the proto), and lattice_control via publish_command. automatic_config also publishes inverter_serial so inverter.py can correlate each unit to its node. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/gateway.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 3fbaefd67..84802a0b4 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -667,6 +667,44 @@ def _needs_reconfigure(self, status): return True return False + def lattice_fragment(self): + """Export a Lattice fragment for the inverters this gateway reaches locally over Modbus. + + Thin adapter over lattice.inverter_fragment: pulls the battery inverters from the + latest gateway status and offers charge/discharge-rate control on a high-preference + local access path. The merge/resolve logic lives in (and is unit-tested via) lattice.py. + """ + from lattice import inverter_fragment + + inverters = [] + status = getattr(self, "_last_status", None) + for inv in status.inverters if status else []: + if inv.battery.ByteSize() > 0 or inv.battery.capacity_wh > 0: + inverters.append({"serial": inv.serial, "device_type": self._lattice_device_type(inv.type)}) + return inverter_fragment(inverters, provider="local-gateway", name="Local gateway", transport="modbus", preference=10, locality="local", controllable=("charge_rate", "discharge_rate")) + + @staticmethod + def _lattice_device_type(type_value): + """Best-effort readable device type from the gateway proto inverter-type enum (e.g. 'givenergy_aio').""" + try: + return pb.InverterType.Name(type_value).replace("INVERTER_TYPE_", "").lower() + except Exception: + return "inverter" + + async def lattice_control(self, node_id, capability, value): + """Execute a Lattice-resolved control write on an inverter via an MQTT command. + + Maps the capability to a gateway command (charge/discharge rate) and publishes it + for the given serial. Returns True on publish, False if the capability is unsupported + or MQTT is not connected (so the projection falls back to another provider). + """ + commands = {"charge_rate": "set_charge_rate", "discharge_rate": "set_discharge_rate"} + command = commands.get(capability) + if command is None or not getattr(self, "_mqtt_connected", False): + return False + await self.publish_command(command, power_w=int(value), serial=node_id) + return True + def automatic_config(self): """Register gateway entities with PredBat's inverter model. @@ -827,6 +865,8 @@ def automatic_config(self): self.set_arg("scheduled_discharge_enable", discharge_enable_entities) self.set_arg("export_limit", export_limit_entities) self.set_arg("inverter_limit", inverter_limit_entities) + # Per-index real serials so inverter.py can correlate each inverter with its Lattice node. + self.set_arg("inverter_serial", [inv.serial for inv in inverters]) # Energy counters (first inverter) suffix0 = inverters[0].serial[-6:].lower() From 35753eea74e7cf6cc157ac2b436f75d44be31af7 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Thu, 18 Jun 2026 09:51:23 +0100 Subject: [PATCH 2/2] feat(lattice): GE-Cloud producer (read-only, dormant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GECloudDirect.lattice_fragment emits the cloud-reachable inverters; control stays read-only because GE-Cloud settings are keyed by numeric id while the mapping is by name — resolve name->setting-id + verify live before declaring control. The local gateway is the GE executor. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/gecloud.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index b4ce3e640..67212da25 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -254,6 +254,40 @@ def initialize(self, ge_cloud_direct, api_key, automatic): self.requests_total = 0 self.failures_total = 0 + def lattice_fragment(self): + """Export a Lattice fragment for the inverter(s) controllable via the GivEnergy Cloud API. + + Emits the same battery serials the cloud polls (self.device_list) on a LOW-preference + cloud access path, so the merge yields one node with [gateway(preferred), GE-Cloud(fallback)]. + """ + from lattice import inverter_fragment + + inverters = [{"serial": serial, "device_type": "ge-aio"} for serial in getattr(self, "device_list", []) or []] + # READ-ONLY: lattice_control below keys writes by register NAME, but self.settings is keyed by + # numeric setting id — so it can't execute yet. Declaring control would over-promise (the + # gateway->cloud fallback would silently no-op). Flip controllable once name->sid resolution + # is implemented + verified (see lattice_control). The local-gateway path is the GE executor today. + return inverter_fragment(inverters, provider="ge-cloud", name="GivEnergy Cloud", transport="https", preference=1, locality="cloud", controllable=()) + + async def lattice_control(self, node_id, capability, value): + """Execute a Lattice-resolved control write via the GivEnergy Cloud setting API. + + Maps the capability to a GE setting key and writes it for the serial. Writes only when + that serial actually exposes the setting; returns False otherwise so the projection can + fall back. KNOWN GAP (why the fragment is read-only): self.settings is keyed by NUMERIC + setting id, but these are register NAMES, so the guard never matches and this can't execute + yet. Resolve name->setting-id (via the device's settings) + verify live before declaring control. + """ + keys = {"charge_rate": "battery_charge_power", "discharge_rate": "battery_discharge_power"} + key = keys.get(capability) + if key is None: + return False + if key not in self.settings.get(node_id, {}): + self.log("GECloud: lattice_control: setting {} not available for {}".format(key, node_id)) + return False + result = await self.async_write_inverter_setting(node_id, key, int(value)) + return bool(result) + async def switch_event(self, entity_id, service): """ Switch event