Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading