Skip to content

Commit afb47e0

Browse files
authored
feat(energy): add local-Powerwall key-ownership merge contract (#145)
Add merge_local_into_cloud plus LOCAL_LIVE_STATUS_KEYS/LOCAL_SITE_INFO_KEYS to router/energysite.py so a consumer can overlay local Powerwall readings onto a cloud response without clobbering fields the local gateway can't serve. PowerwallEnergySite.live_status() returns all cloud keys with None for unservable ones, so presence-in-response can't be the ownership test - a fixed owned-key set is what makes the overlay safe and lets a local outage fall back to the cloud value. Router dispatch is unchanged; this is a plain function next to EnergySiteRouter, not new Router/health logic. Claude-Session: https://claude.ai/code/session_01B18NQ2RAaJAX2rsiU31Qn6
1 parent d6b5fe9 commit afb47e0

5 files changed

Lines changed: 164 additions & 2 deletions

File tree

docs/energy_local_control.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,39 @@ and the `health` check).
266266
> status read (for example `live_status()`'s grid/island fields) before
267267
> trusting the response for anything actuation-critical.
268268
269+
### Merge contract
270+
271+
`EnergySiteRouter`'s per-command dispatch is **not** a merge - calling
272+
`router.live_status()` returns one whole source's response (local, or cloud on
273+
failover), not a blend of both. `PowerwallEnergySite.live_status()` reports all
274+
of the cloud response's keys but fills in `None` for the ones it cannot serve
275+
locally, so a consumer that wants "local readings where available, cloud
276+
otherwise" needs to merge the two responses itself, field by field.
277+
278+
`tesla_fleet_api.router.energysite` provides that merge as a plain function,
279+
independent of `Router`:
280+
281+
```python
282+
from tesla_fleet_api.router.energysite import (
283+
LOCAL_LIVE_STATUS_KEYS,
284+
LOCAL_SITE_INFO_KEYS,
285+
merge_local_into_cloud,
286+
)
287+
288+
cloud_status = await teslemetry_energysite.live_status()
289+
local_status = await local_energysite.live_status()
290+
merged = merge_local_into_cloud(cloud_status, local_status, LOCAL_LIVE_STATUS_KEYS)
291+
```
292+
293+
- `LOCAL_LIVE_STATUS_KEYS` are the `live_status()` fields the local gateway can
294+
actually serve; `LOCAL_SITE_INFO_KEYS` are the `site_info()` equivalent
295+
(`backup_reserve_percent`, `default_real_mode`).
296+
- Only keys in the given set **and** present in `local` are overlaid onto a
297+
copy of `cloud`; every other key keeps its cloud value.
298+
- If the local read failed entirely (`local is None`, e.g. the LAN call
299+
raised), the result is just `cloud` - the same fallback-to-cloud behavior
300+
`Router` gives non-merged commands.
301+
269302
## See also
270303

271304
- [Fleet API for Energy Sites](fleet_api_energy_sites.md) - the cloud

tesla_fleet_api/router/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
from tesla_fleet_api.router.base import HealthCheck, Router
44
from tesla_fleet_api.router.vehicle import VehicleRouter
5-
from tesla_fleet_api.router.energysite import EnergySiteRouter
5+
from tesla_fleet_api.router.energysite import (
6+
EnergySiteRouter,
7+
LOCAL_LIVE_STATUS_KEYS,
8+
LOCAL_SITE_INFO_KEYS,
9+
merge_local_into_cloud,
10+
)
611

712
__all__ = [
813
"Router",
914
"VehicleRouter",
1015
"EnergySiteRouter",
1116
"HealthCheck",
17+
"LOCAL_LIVE_STATUS_KEYS",
18+
"LOCAL_SITE_INFO_KEYS",
19+
"merge_local_into_cloud",
1220
]

tesla_fleet_api/router/energysite.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,47 @@
11
from __future__ import annotations
22

3+
from typing import Any
4+
35
from tesla_fleet_api.router.base import PrimaryT, Router, SecondaryT
46

7+
LOCAL_LIVE_STATUS_KEYS: frozenset[str] = frozenset(
8+
{
9+
"solar_power",
10+
"energy_left",
11+
"total_pack_energy",
12+
"percentage_charged",
13+
"battery_power",
14+
"load_power",
15+
"grid_power",
16+
"generator_power",
17+
"grid_status",
18+
"island_status",
19+
}
20+
)
21+
LOCAL_SITE_INFO_KEYS: frozenset[str] = frozenset(
22+
{"backup_reserve_percent", "default_real_mode"}
23+
)
24+
25+
26+
def merge_local_into_cloud(
27+
cloud: dict[str, Any], local: dict[str, Any] | None, owned_keys: frozenset[str]
28+
) -> dict[str, Any]:
29+
"""Overlay owned_keys present in local onto a copy of cloud; every other key keeps its cloud value.
30+
31+
``PowerwallEnergySite.live_status()`` returns all cloud keys with ``None``
32+
for the ones it cannot serve, so presence-in-response cannot be the
33+
ownership test — a fixed owned-key set lets a caller overlay local
34+
readings without clobbering cloud values, and lets a local outage fall
35+
back to the cloud value instead of an unavailable one.
36+
"""
37+
merged = dict(cloud)
38+
if local is None:
39+
return merged
40+
for key in owned_keys:
41+
if key in local:
42+
merged[key] = local[key]
43+
return merged
44+
545

646
class EnergySiteRouter(Router[PrimaryT, SecondaryT]):
747
"""A :class:`Router` over energy-site instances.

tesla_fleet_api/tesla/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
from tesla_fleet_api.tesla.charging import Charging
77
from tesla_fleet_api.tesla.energysite import EnergySites, EnergySite
88
from tesla_fleet_api.tesla.partner import Partner
9-
from tesla_fleet_api.router import Router, VehicleRouter, EnergySiteRouter
9+
from tesla_fleet_api.router import (
10+
Router,
11+
VehicleRouter,
12+
EnergySiteRouter,
13+
LOCAL_LIVE_STATUS_KEYS,
14+
LOCAL_SITE_INFO_KEYS,
15+
merge_local_into_cloud,
16+
)
1017
from tesla_fleet_api.tesla.user import User
1118
from tesla_fleet_api.tesla.vehicle import (
1219
Vehicles,
@@ -35,4 +42,7 @@
3542
"VehicleBluetooth",
3643
"Router",
3744
"VehicleRouter",
45+
"LOCAL_LIVE_STATUS_KEYS",
46+
"LOCAL_SITE_INFO_KEYS",
47+
"merge_local_into_cloud",
3848
]
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Unit tests for the local-Powerwall key-ownership merge contract.
2+
3+
`merge_local_into_cloud` is a plain function, independent of `Router`
4+
dispatch - see docs/energy_local_control.md's "Merge contract" section.
5+
"""
6+
7+
import unittest
8+
9+
from tesla_fleet_api.router import (
10+
LOCAL_LIVE_STATUS_KEYS,
11+
LOCAL_SITE_INFO_KEYS,
12+
merge_local_into_cloud,
13+
)
14+
15+
16+
class TestMergeLocalIntoCloud(unittest.TestCase):
17+
def test_local_none_returns_cloud_values(self) -> None:
18+
cloud = {"solar_power": 100, "grid_power": 50, "generator_power": 0}
19+
result = merge_local_into_cloud(cloud, None, LOCAL_LIVE_STATUS_KEYS)
20+
self.assertEqual(result, cloud)
21+
22+
def test_owned_key_present_in_local_is_overlaid(self) -> None:
23+
cloud = {"solar_power": 100, "grid_power": 50}
24+
local = {"solar_power": 200}
25+
result = merge_local_into_cloud(cloud, local, LOCAL_LIVE_STATUS_KEYS)
26+
self.assertEqual(result["solar_power"], 200)
27+
self.assertEqual(result["grid_power"], 50)
28+
29+
def test_owned_key_absent_from_local_keeps_cloud_value(self) -> None:
30+
cloud = {"solar_power": 100, "grid_power": 50}
31+
local: dict = {"solar_power": 200}
32+
result = merge_local_into_cloud(cloud, local, LOCAL_LIVE_STATUS_KEYS)
33+
self.assertEqual(result["grid_power"], 50)
34+
35+
def test_falsy_but_present_local_value_is_overlaid(self) -> None:
36+
cloud = {"grid_power": 999}
37+
local = {"grid_power": 0}
38+
result = merge_local_into_cloud(cloud, local, LOCAL_LIVE_STATUS_KEYS)
39+
self.assertEqual(result["grid_power"], 0)
40+
41+
def test_local_keys_outside_owned_set_are_ignored(self) -> None:
42+
cloud = {"solar_power": 100}
43+
local = {"solar_power": 200, "some_unowned_field": "surprise"}
44+
result = merge_local_into_cloud(cloud, local, LOCAL_LIVE_STATUS_KEYS)
45+
self.assertNotIn("some_unowned_field", result)
46+
47+
def test_does_not_mutate_inputs(self) -> None:
48+
cloud = {"solar_power": 100}
49+
local = {"solar_power": 200}
50+
cloud_copy = dict(cloud)
51+
local_copy = dict(local)
52+
result = merge_local_into_cloud(cloud, local, LOCAL_LIVE_STATUS_KEYS)
53+
self.assertEqual(cloud, cloud_copy)
54+
self.assertEqual(local, local_copy)
55+
self.assertIsNot(result, cloud)
56+
57+
def test_returns_new_dict_when_local_is_none(self) -> None:
58+
cloud = {"solar_power": 100}
59+
result = merge_local_into_cloud(cloud, None, LOCAL_LIVE_STATUS_KEYS)
60+
self.assertIsNot(result, cloud)
61+
62+
def test_site_info_keys(self) -> None:
63+
cloud = {"backup_reserve_percent": 20, "default_real_mode": "self_consumption"}
64+
local = {"backup_reserve_percent": 30}
65+
result = merge_local_into_cloud(cloud, local, LOCAL_SITE_INFO_KEYS)
66+
self.assertEqual(result["backup_reserve_percent"], 30)
67+
self.assertEqual(result["default_real_mode"], "self_consumption")
68+
69+
70+
if __name__ == "__main__":
71+
unittest.main()

0 commit comments

Comments
 (0)