Skip to content

feat(teslemetry): add find_gateway_address() typed accessor for Powerwall LAN discovery#81

Merged
Bre77 merged 3 commits into
mainfrom
fm/tfa-gateway-address-f8
Jul 14, 2026
Merged

feat(teslemetry): add find_gateway_address() typed accessor for Powerwall LAN discovery#81
Bre77 merged 3 commits into
mainfrom
fm/tfa-gateway-address-f8

Conversation

@Bre77

@Bre77 Bre77 commented Jul 14, 2026

Copy link
Copy Markdown
Member

Intent

Add TeslemetryEnergySite.find_gateway_address(), a typed accessor that discovers the Powerwall gateway's LAN IPv4 from the networking_status command, so Home Assistant's local-control config flow (core PR #176328) can pre-fill the host instead of requiring the user to type it manually. Follows the exact precedent of the existing find_authorized_clients() typed accessor (module-level helper functions, key-presence checks, InvalidResponse only on null body or unrecognized shape, None for a well-formed-but-empty result). Real payload shape came from a live Powerwall 3 capture (networking_status response with wifi_config, and per-interface wifi/eth/gsm blocks each carrying ipv4_config). Key technical decision: ipv4_config.address/subnet_mask/gateway are raw big-endian uint32 integers on the wire, not dotted-quad strings - verified 3232235914 decodes big-endian to 192.168.1.138 (a plausible RFC1918 address) and confirmed against a matching gateway value; little-endian was ruled out as implausible. Selection heuristic (deliberately specified, matches the analogous Home Assistant Teslemetry integration's own _extract_host helper): consider only eth and wifi interfaces, never gsm (cellular is not a LAN path); prefer whichever has active_route set and a decodable address; otherwise fall back to the first of eth/wifi (in that order) with any decodable address; return None when neither yields a usable address. Added tests/test_teslemetry_gateway_address.py with 11 cases: the real captured sample (anonymized identifiers, structure verbatim) selecting the active-route wifi address, an eth-preferred case, two no-active-route fallback cases, gsm-only returning None, empty/missing-interface cases returning None, a bare-body (no response envelope) case, and null-body/list-body/unrecognized-shape cases raising InvalidResponse. Full test suite (358 tests) passes, ruff check/format and pyright strict are clean on the changed files. Also added a knowledge entry to AGENTS.md documenting the uint32-decoding gotcha and the selection heuristic, alongside the existing find_authorized_clients entry it extends. Note for the PR: this change should mention it unblocks HA core PR #176328's local-control IP auto-discovery, and that release 1.7.5 follows immediately once merged.

What Changed

  • Added TeslemetryEnergySite.find_gateway_address() (tesla_fleet_api/teslemetry/energysite.py), a typed accessor over the networking_status command that returns the Powerwall gateway's LAN IPv4 as a dotted-quad string. It decodes ipv4_config.address as a raw big-endian uint32 (verified against a live Powerwall 3 capture where 3232235914 decodes to 192.168.1.138), considers only eth/wifi interfaces (never gsm), prefers the interface with active_route set, and falls back to the first of eth/wifi with a decodable address.
  • Hardened the edge cases per review: a {"response": null} envelope raises InvalidResponse (matching the sibling find_authorized_clients() accessor and the endpoint's known intermittent null-body mode), while 0/0xFFFFFFFF addresses are treated as undecodable so an unconfigured interface never shadows a real one with 0.0.0.0; a well-formed response with no usable interface returns None.
  • Added tests/test_teslemetry_gateway_address.py (15 cases covering the real capture, eth/wifi preference and fallback, gsm exclusion, zero/broadcast rejection, and malformed-shape raises) and documented the accessor in docs/teslemetry.md and docs/energy_local_control.md. This unblocks host auto-discovery in Home Assistant's local-control config flow (core PR #176328).

Risk Assessment

✅ Low: A small, additive, well-tested typed accessor following an established in-repo pattern; all prior review findings were verifiably fixed with pinned regression tests, and the only remaining note is optional test-helper dedup.

Testing

Ran the 15 new gateway-address tests and the full 362-test suite (all pass), then demonstrated the feature end-to-end by pointing a real Teslemetry client at a local HTTP server serving the live Powerwall 3 capture: find_gateway_address() decoded the raw big-endian uint32 wire value to 192.168.1.138 from the active-route wifi interface (gsm ignored), and the intermittent {"response": null} malformed mode raised InvalidResponse as designed.

Evidence: End-to-end demo transcript (real HTTP server + real client stack)

Live-captured wire values (raw big-endian uint32, not strings): wifi.ipv4_config = {"dhcp_enabled": true, "address": 3232235914, "subnet_mask": 4294967040, "gateway": 3232235777} === Case 1: real Powerwall 3 capture (wifi active_route, eth inactive, gsm ignored) === [server] serving networking_status at http://127.0.0.1:44311/api/1/energy_sites/12345/command/networking_status
[server] GET /api/1/energy_sites/12345/command/networking_status [server] Authorization: Bearer demo-token [client] find_gateway_address() -> '192.168.1.138' [HA config flow] would pre-fill local-control host field with: 192.168.1.138 === Case 2: intermittent malformed {"response": null} body === [server] serving networking_status at http://127.0.0.1:39253/api/1/energy_sites/12345/command/networking_status
[server] GET /api/1/energy_sites/12345/command/networking_status [server] Authorization: Bearer demo-token [client] find_gateway_address() raised InvalidResponse: InvalidResponse('The response from Tesla was invalid.')

Live-captured wire values (raw big-endian uint32, not strings):
   wifi.ipv4_config = {"dhcp_enabled": true, "address": 3232235914, "subnet_mask": 4294967040, "gateway": 3232235777}

=== Case 1: real Powerwall 3 capture (wifi active_route, eth inactive, gsm ignored) ===
   [server] serving networking_status at http://127.0.0.1:44311/api/1/energy_sites/12345/command/networking_status
   [server] GET /api/1/energy_sites/12345/command/networking_status
   [server] Authorization: Bearer demo-token
   [client] find_gateway_address() -> '192.168.1.138'
   [HA config flow] would pre-fill local-control host field with: 192.168.1.138

=== Case 2: intermittent malformed {"response": null} body ===
   [server] serving networking_status at http://127.0.0.1:39253/api/1/energy_sites/12345/command/networking_status
   [server] GET /api/1/energy_sites/12345/command/networking_status
   [server] Authorization: Bearer demo-token
   [client] find_gateway_address() raised InvalidResponse: InvalidResponse('The response from Tesla was invalid.')
Evidence: End-to-end demo script
"""End-to-end demo of TeslemetryEnergySite.find_gateway_address().

Spins up a real local aiohttp HTTP server impersonating api.teslemetry.com,
serving the (anonymized) live Powerwall 3 networking_status capture, then
drives the real Teslemetry client stack - actual ClientSession, actual
_request()/raise_for_status()/JSON decode - the same way Home Assistant's
local-control config flow would, to pre-fill the gateway host.
"""

import asyncio
import json

import aiohttp
from aiohttp import web

from tesla_fleet_api.exceptions import InvalidResponse
from tesla_fleet_api.teslemetry.teslemetry import Teslemetry

SITE_ID = 12345

# Structure verbatim from the live Powerwall 3 capture (identifiers anonymized):
# wifi is the active route, eth is populated but inactive, gsm is cellular.
REAL_CAPTURE = {
    "response": {
        "wifi_config": {"ssid": "ANONYMIZED_SSID"},
        "wifi": {
            "mac_address": "ANONYMIZED_MAC_1",
            "enabled": True,
            "active_route": True,
            "ipv4_config": {
                "dhcp_enabled": True,
                "address": 3232235914,
                "subnet_mask": 4294967040,
                "gateway": 3232235777,
            },
            "connectivity_status": {
                "connected_physical": True,
                "connected_internet": True,
                "connected_tesla": True,
                "rssi": {"signal_strength_percent": {"value": 42}},
            },
            "device_state": 6,
            "device_state_reason": 1,
        },
        "eth": {
            "mac_address": "ANONYMIZED_MAC_2",
            "enabled": True,
            "ipv4_config": {
                "dhcp_enabled": True,
                "address": 3232258562,
                "subnet_mask": 4294967040,
            },
            "connectivity_status": {"rssi": {"signal_strength_percent": {}}},
        },
        "gsm": {
            "enabled": True,
            "ipv4_config": {
                "address": 168866907,
                "subnet_mask": 4294967295,
                "gateway": 168866907,
            },
            "connectivity_status": {
                "connected_physical": True,
                "connected_internet": True,
                "connected_tesla": True,
                "rssi": {"signal_strength_percent": {"value": 60}},
            },
        },
    }
}

NULL_ENVELOPE = {"response": None}  # the endpoint's known intermittent bad mode

PATH = f"/api/1/energy_sites/{SITE_ID}/command/networking_status"


def make_app(body: object) -> web.Application:
    async def networking_status(request: web.Request) -> web.Response:
        print(f"   [server] {request.method} {request.path}")
        print(f"   [server] Authorization: {request.headers.get('Authorization')}")
        return web.json_response(body)

    app = web.Application()
    app.router.add_get(PATH, networking_status)
    return app


async def run_case(title: str, body: object) -> None:
    print(f"\n=== {title} ===")
    runner = web.AppRunner(make_app(body))
    await runner.setup()
    site_tcp = web.TCPSite(runner, "127.0.0.1", 0)
    await site_tcp.start()
    port = runner.addresses[0][1]
    server = f"http://127.0.0.1:{port}"
    print(f"   [server] serving networking_status at {server}{PATH}")

    async with aiohttp.ClientSession() as session:
        api = Teslemetry(session=session, access_token="demo-token", server=server)
        energy_site = api.energySites.create(SITE_ID)
        try:
            host = await energy_site.find_gateway_address()
        except InvalidResponse as err:
            print(f"   [client] find_gateway_address() raised InvalidResponse: {err!r}")
        else:
            print(f"   [client] find_gateway_address() -> {host!r}")
            if host is not None:
                print(
                    f"   [HA config flow] would pre-fill local-control host field with: {host}"
                )

    await runner.cleanup()


async def main() -> None:
    print("Live-captured wire values (raw big-endian uint32, not strings):")
    wifi = REAL_CAPTURE["response"]["wifi"]["ipv4_config"]
    print(f"   wifi.ipv4_config = {json.dumps(wifi)}")

    await run_case(
        "Case 1: real Powerwall 3 capture (wifi active_route, eth inactive, gsm ignored)",
        REAL_CAPTURE,
    )
    await run_case(
        'Case 2: intermittent malformed {"response": null} body', NULL_ENVELOPE
    )


asyncio.run(main())

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ tesla_fleet_api/teslemetry/energysite.py:196 - A response of {"response": null} silently returns None instead of raising InvalidResponse: _networking_status_body only unwraps the envelope when response is a dict, so a null nested under the envelope leaves body as {"response": None}, the eth/wifi lookups miss, and the result is indistinguishable from a well-formed no-LAN-interface response. The sibling _authorized_clients_list raises for the analogous shape, and AGENTS.md documents these Teslemetry command endpoints intermittently returning null payloads, so this collapses a known malformed-data mode into "no address". The docstring suggests the any-dict-is-well-formed boundary was deliberate, so confirm intent; if changed, add a test pinning the {"response": null} behavior.
  • ℹ️ tesla_fleet_api/teslemetry/energysite.py:168 - _decode_ipv4(0) returns "0.0.0.0", which _interface_address treats as a usable address. In the no-active-route fallback (eth tried before wifi), an unconfigured eth block reporting address: 0 (e.g. unplugged port, no DHCP lease) would shadow a real wifi address and pre-fill 0.0.0.0 as the local-control host. Consider rejecting 0 (and possibly 0xFFFFFFFF) as undecodable. Speculative about actual wire behavior - the live capture's inactive eth carried a real address - so flagging for the author's judgment rather than as a defect.
  • ℹ️ AGENTS.md:146 - The new AGENTS.md entry says find_gateway_address() "decodes ipv4_config.address/subnet_mask/gateway", but the code only reads and decodes ipv4_config.address; subnet_mask/gateway are never touched. Reword to say the wire format applies to all three fields but only address is decoded, so future sessions aren't misled about the accessor's surface.

🔧 Fix: reject null envelope and 0/broadcast addresses in gateway lookup
1 info still open:

  • ℹ️ tests/test_teslemetry_gateway_address.py:30 - The _fake_response/_make_session/_make_site mock helpers are duplicated verbatim from tests/test_teslemetry_authorized_clients.py. With a third typed accessor likely to follow the same pattern, extracting them into a shared test helper module (e.g. tests/teslemetry_mock_helpers.py) would remove ~30 duplicated lines. Purely optional dedup; both copies are correct.
✅ **Test** - passed

✅ No issues found.

  • uv run pytest tests/test_teslemetry_gateway_address.py -v (15 passed: real capture selection, eth/wifi preference and fallback, gsm exclusion, 0/broadcast address rejection, None for well-formed-but-empty, InvalidResponse for null body/null envelope/list/scalar shapes)
  • uv run pytest tests (full suite, 362 passed, no regressions)
  • End-to-end manual check: real aiohttp server impersonating api.teslemetry.com serving the captured networking_status payload, real Teslemetry client + energySites.create(12345) calling find_gateway_address() over an actual HTTP round trip -> '192.168.1.138'; second run with {"response": null} -> InvalidResponse raised
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

firstmate crewmate added 3 commits July 14, 2026 16:59
Discovers the gateway's LAN IPv4 from networking_status so Home
Assistant's local-control config flow can pre-fill the host instead of
requiring the user to type it. Follows the find_authorized_clients()
typed-accessor precedent: eth/wifi only (never gsm), prefers the
active-route interface, falls back to the first with a decodable
address, and decodes ipv4_config's raw big-endian uint32 fields into
dotted-quad form.
@Bre77 Bre77 merged commit f9c88aa into main Jul 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant