Skip to content

docs(energy): document local control pairing#78

Merged
Bre77 merged 8 commits into
mainfrom
fm/tfa-energy-local-docs
Jul 13, 2026
Merged

docs(energy): document local control pairing#78
Bre77 merged 8 commits into
mainfrom
fm/tfa-energy-local-docs

Conversation

@Bre77

@Bre77 Bre77 commented Jul 12, 2026

Copy link
Copy Markdown
Member

Intent

Amend PR 78 per captain direction: rework the pairing-verification guidance in docs/energy_local_control.md. Polling the cloud list_authorized_clients/find_authorized_clients endpoint can no longer be the primary verification method, since Tesla currently returns a bare-null-200 body on that endpoint and this may recur. Made the primary verification method: attempt a signed local read through aiopowerwall (using live_status(), since get_system_info()/get_status() are NotImplementedError placeholders on PowerwallEnergySite and would prove nothing) in a retry loop with exponential backoff, treating aiopowerwall.PowerwallAuthenticationError as expected/not-yet-verified rather than an error, and treating a successful signed read as proof the key is VERIFIED. Demoted the cloud list_authorized_clients/find_authorized_clients poll to a secondary, best-effort check with an explicit warning that it must never override a signed local read's result. This required reordering the doc's steps slightly (register -> hand off to aiopowerwall -> verify by using it -> compose with EnergySiteRouter) since verification-by-use needs the aiopowerwall client to already exist; everything else in the PR (intro, warning box about island-mode actuation, See Also section) is unchanged. Docs-only change, no behavior changes.

What Changed

  • Documented the local energy control pairing flow, including Tesla Fleet API client registration, aiopowerwall handoff, signed local-read verification, and EnergySiteRouter composition with Teslemetry fallback.
  • Clarified that cloud authorized-client polling is only a secondary, best-effort verification path and must not override a successful signed local read.
  • Updated energy-site docs and docstrings around authorized clients, key verification, and local-control fallback behavior.

Risk Assessment

✅ Low: The changes are documentation/docstring-only, tightly scoped to energy local-control pairing guidance, and the previously reported stale verification guidance has been corrected.

Testing

Exercised the focused authorized-client parser and EnergySiteRouter behavior, produced reviewer-visible rendered HTML evidence for the docs flow, then ran the full pytest suite; all checks passed and transient pytest caches were removed.

Evidence: Rendered energy local-control documentation evidence
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Energy Local Control Documentation Evidence</title>
<style>
:root { color-scheme: light; --ink:#16202a; --muted:#5a6878; --line:#d8dee6; --panel:#f7f9fb; --ok:#0f7b45; --warn:#8a5a00; --code:#f0f3f6; }
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color:var(--ink); background:white; }
header { padding:32px 40px 20px; border-bottom:1px solid var(--line); background:#fbfcfd; }
header h1 { margin:0 0 8px; font-size:28px; line-height:1.2; }
header p { margin:0; color:var(--muted); max-width:920px; }
main { display:grid; grid-template-columns:minmax(280px, 380px) minmax(0, 1fr); gap:28px; padding:28px 40px 48px; }
aside { align-self:start; position:sticky; top:20px; border:1px solid var(--line); border-radius:8px; background:var(--panel); padding:18px; }
aside h2 { font-size:16px; margin:0 0 12px; }
.check { display:flex; gap:10px; align-items:flex-start; padding:10px 0; border-top:1px solid var(--line); font-size:14px; line-height:1.35; }
.check:first-of-type { border-top:0; }
.check span { flex:0 0 auto; min-width:44px; font-size:12px; font-weight:700; color:white; background:var(--ok); border-radius:4px; text-align:center; padding:2px 5px; }
.check.bad span { background:#b42318; }
.doc { max-width:980px; min-width:0; }
.doc h1 { font-size:34px; margin:0 0 20px; line-height:1.15; }
.doc h2 { margin:34px 0 12px; padding-top:18px; border-top:1px solid var(--line); font-size:22px; }
.doc p, .doc li { line-height:1.58; font-size:16px; }
ul { padding-left:24px; }
code { background:var(--code); padding:1px 4px; border-radius:4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.92em; }
pre { overflow:auto; background:#0f1720; color:#e5edf5; padding:16px; border-radius:8px; line-height:1.45; }
pre code { background:transparent; padding:0; color:inherit; }
blockquote { margin:24px 0; padding:14px 18px; border-left:5px solid var(--warn); background:#fff8e8; border-radius:0 8px 8px 0; }
blockquote p { margin:8px 0; }
a { color:#165d9f; }
@media (max-width: 860px) { main { grid-template-columns:1fr; padding:20px; } aside { position:static; } header { padding:24px 20px 18px; } }
</style>
</head>
<body>
<header>
<h1>Rendered Documentation Evidence</h1>
<p>Source: <code>docs/energy_local_control.md</code>. This artifact renders the end-user doc and verifies the specific pairing-verification intent.</p>
</header>
<main>
<aside>
<h2>Intent Checks</h2>
<div class="check ok"><span>PASS</span>Primary verification is signed local use</div>
<div class="check ok"><span>PASS</span>Uses live_status(), not unimplemented placeholders</div>
<div class="check ok"><span>PASS</span>PowerwallAuthenticationError is expected during retry</div>
<div class="check ok"><span>PASS</span>Cloud polling is explicitly secondary and non-authoritative</div>
<div class="check ok"><span>PASS</span>Flow order is register -&gt; hand to aiopowerwall -&gt; verify -&gt; router</div>
</aside>
<article class="doc">
<h1>Energy: Local Control (aiopowerwall + EnergySiteRouter)</h1><p>This library and the sibling <a href="https://pypi.org/project/aiopowerwall/"><code>aiopowerwall</code></a> library are designed to be used together to give an energy gateway (Powerwall 2/3) both a local, signed LAN control path and a cloud fallback:</p><ul><li><strong>This repo holds the keypair lifecycle.</strong> <code>Tesla.get_rsa_private_key</code></li><p>  generates or loads and persists the RSA key, <code>rsa_public_der_pkcs1</code> derives   the public key bytes for registration, and   <code>EnergySite.add_authorized_client</code> registers its public half with the   gateway over the cloud. <code>aiopowerwall</code> deliberately does <strong>not</strong> implement   registration - it only consumes an already-paired key.</p><li><strong>aiopowerwall holds the local, RSA-signed transport.</strong> Its</li><p>  <code>PowerwallEnergySite</code> adapter implements the local subset of this repo&#x27;s   <code>EnergySite</code> surface with matching names, signatures, and <code>dict[str, Any]</code>   return shapes, without importing this package - it is duck-typed by design   so it drops straight into <code>EnergySiteRouter</code>.</p></ul><p><code>aiopowerwall</code> is not a dependency of this project; nothing here imports it. You add it to your own application alongside <code>tesla-fleet-api</code>.</p><h2>1. Generate or load the RSA private key</h2><p><code>get_rsa_private_key(path)</code> (available on <code>Tesla</code> and everything that inherits it - <code>TeslaFleetApi</code>, <code>Teslemetry</code>, <code>Tessie</code>) loads an existing RSA private key or creates a new 4096-bit unencrypted PEM key file. This is the key you will register with the gateway and later hand to <code>aiopowerwall</code>.</p><h2>2. Register the key with the gateway, over the cloud</h2><p><code>EnergySite.add_authorized_client</code> registers the public half of that key with the gateway. After registration the key sits in <code>PENDING</code>/ <code>PENDING_VERIFICATION</code> state (<code>AuthorizedClientState</code>) until the gateway confirms it - either auto-verified via cloud, or by a physical breaker toggle at the gateway.</p><pre><code class="language-python">import aiohttp
from tesla_fleet_api import Teslemetry

async def register_key(session: aiohttp.ClientSession, energy_site_id: int):
    api = Teslemetry(access_token=&quot;&lt;access_token&gt;&quot;, session=session)

    # Creates tedapi_rsa_private.pem (mode 0600) on first run, or loads
    # the existing key on subsequent runs.
    await api.get_rsa_private_key(&quot;tedapi_rsa_private.pem&quot;)

    energy_site = api.energySites.create(energy_site_id)
    await energy_site.add_authorized_client(
        api.rsa_public_der_pkcs1_b64,
        description=&quot;My local control client&quot;,
    )
    return api, energy_site</code></pre><h2>3. Hand the paired key to aiopowerwall</h2><p><code>aiopowerwall</code>&#x27;s <code>PowerwallClient</code> consumes the <em>same</em> PEM file directly - it does not need anything else from this library at this point. <code>local_energysite</code> then exposes the locally implemented <code>EnergySite</code>-compatible calls (<code>live_status()</code>, <code>operation()</code>, <code>backup()</code>, <code>set_island_mode()</code>, <code>get_backup_events()</code>, and backup-event scheduling) through signed requests directly to the gateway over the LAN:</p><pre><code class="language-python">import aiohttp
from aiopowerwall import PowerwallClient
from aiopowerwall.energysite import PowerwallEnergySite

async def make_local_energysite(session: aiohttp.ClientSession) -&gt; PowerwallEnergySite:
    with open(&quot;tedapi_rsa_private.pem&quot;, &quot;rb&quot;) as f:
        rsa_private_key_pem = f.read()

    powerwall_client = PowerwallClient(
        host=&quot;192.168.91.1&quot;,       # the gateway&#x27;s local IP
        gateway_password=&quot;&lt;gateway-password&gt;&quot;,  # local gateway password, not your Tesla account password
        rsa_private_key_pem=rsa_private_key_pem,
        session=session,
    )
    return PowerwallEnergySite(powerwall_client)</code></pre><h2>4. Verify the key is paired by using it</h2><p>The gateway takes registration (step 2) and confirmation (auto-verify, or a physical breaker toggle) as two separate events, and there is a window between them where the key exists but is not yet usable. <strong>The reliable way to tell that window has closed is to attempt a signed local read through <code>aiopowerwall</code> and retry until it succeeds</strong> - a successful signed response <em>is</em> proof the key is <code>VERIFIED</code>, because the gateway would otherwise reject it.</p><p><code>get_system_info()</code>/<code>get_status()</code>-style reads are the natural choice for this, but <code>PowerwallEnergySite</code> does not implement them locally yet (they raise <code>NotImplementedError</code> and would tell you nothing about the key - see the <code>EnergySiteRouter</code> note in step 5). Use <code>live_status()</code> instead: it is already implemented locally and, under the hood, issues a signed v1r request, so it fails exactly the way an unverified key would fail.</p><p>Before verification, every signed request rejects with <code>aiopowerwall.PowerwallAuthenticationError</code> (the gateway&#x27;s &quot;unknown key id&quot; or &quot;authorization not verified&quot; fault) - <strong>that failure is expected and not a bug</strong>; treat it as &quot;not yet, keep retrying&quot; and back off between attempts:</p><pre><code class="language-python">import asyncio
from aiopowerwall import PowerwallAuthenticationError
from aiopowerwall.energysite import PowerwallEnergySite

async def wait_until_verified(
    local_energysite: PowerwallEnergySite,
    attempts: int = 10,
    initial_delay: float = 5.0,
    max_delay: float = 60.0,
) -&gt; None:
    delay = initial_delay
    for attempt in range(attempts):
        try:
            await local_energysite.live_status()
            return  # a successful signed read proves the key is VERIFIED
        except PowerwallAuthenticationError:
            if attempt == attempts - 1:
                raise
            await asyncio.sleep(delay)
            delay = min(delay * 2, max_delay)</code></pre><p>Only fall back to polling the cloud <code>list_authorized_clients()</code> (or, on <code>Teslemetry</code>, <code>find_authorized_clients()</code>) as a <strong>secondary, best-effort</strong> check - for example while you have no local network path to the gateway yet. Tesla&#x27;s cloud endpoint for this is undocumented, and Teslemetry&#x27;s <code>list_authorized_clients</code> in particular has been observed returning a bare JSON <code>null</code> with a <code>200</code> status rather than an envelope; that behavior may recur, so do not treat this endpoint as authoritative, and never let it override a signed local read that already succeeded or failed. <code>TeslemetryEnergySite.find_authorized_clients()</code> parses this defensively (null body, list vs. dict envelope, <code>state</code> typing) and always returns a typed <code>AuthorizedClients</code> with <code>clients == []</code> rather than raising, which keeps a &quot;not verified yet&quot; read from looking like an error - but a <code>null</code> response here still tells you nothing about whether the key actually works.</p><h2>5. Compose local + cloud with EnergySiteRouter</h2><pre><code class="language-python">import asyncio
import aiohttp
from tesla_fleet_api.router import EnergySiteRouter

async def main():
    async with aiohttp.ClientSession() as session:
        api, teslemetry_energysite = await register_key(session, energy_site_id=12345)
        local_energysite = await make_local_energysite(session)
        await wait_until_verified(local_energysite)

        router = EnergySiteRouter(local_energysite, teslemetry_energysite)

        status = await router.live_status()  # tries local first, cloud on failure
        print(status)

asyncio.run(main())</code></pre><p>Locally implemented commands go over the LAN when the gateway is reachable, and fail over to the Teslemetry cloud otherwise - the same local-primary/cloud-fallback pattern <code>VehicleRouter</code> uses for vehicles. Calls that aiopowerwall does not implement locally yet, including <code>get_system_info()</code> and most energy-device gRPC commands, raise <code>NotImplementedError</code> from the local adapter and then fall through to the cloud backend. See <a href="../README.md#routing-and-failover">the README&#x27;s Routing and Failover section</a> for the general <code>Router</code> semantics (per-command failover, the double-execution caveat, and the <code>health</code> check).</p><blockquote><p>**Warning: island mode / off-grid actuation is not guaranteed on either</p>
<p>transport - always verify by state.**</p>

<p>This repo&#x27;s cloud <code>EnergySite.set_island_mode()</code> / <code>go_off_grid()</code> /</p>
<p><code>reconnect_grid()</code> send an <strong>unsigned</strong> <code>grpc_command</code>. Per their own</p>
<p>docstrings and the commits that added them, the gateway accepts this</p>
<p>command but does <strong>not</strong> physically operate the grid contactor over that</p>
<p>transport - a plain cloud-only <code>EnergySite</code> (no router, no local backend)</p>
<p>gets a silent no-op with an OK-looking response.</p>

<p>Routing through <code>EnergySiteRouter</code> with an <code>aiopowerwall</code> local primary is</p>
<p>the intended way to actually operate the contactor, since it sends a</p>
<p>signed request over the LAN. But <code>aiopowerwall</code>&#x27;s own docs carry an</p>
<p>unresolved, firmware-dependent caveat for its local <code>set_island_mode</code></p>
<p>too - some gateways acknowledge it without acting on it, and</p>
<p><code>trigger_islanding()</code> (the explicit black-start command) may be needed as</p>
<p>a fallback.</p>

<p>In both cases a success-shaped response does not prove the contactor</p>
<p>moved, and <code>Router</code> has no way to detect that on its own - a call that</p>
<p>returns without raising looks identical whether it actuated or not, so</p>
<p>failover to the cloud secondary will not trigger. Always confirm the</p>
<p>actual outcome with a status read (e.g. <code>live_status()</code>&#x27;s grid/island</p>
<p>fields, or aiopowerwall&#x27;s <code>get_status()</code> <code>islanding.contactorClosed</code>)</p>
<p>before trusting either path&#x27;s response for anything actuation-critical.</p></blockquote><h2>See also</h2><ul><li><a href="fleet_api_energy_sites.md">Fleet API for Energy Sites</a> - the cloud</li><p>  <code>EnergySite</code> command surface (<code>get_rsa_private_key</code>, the gRPC &quot;Device   Commands&quot; section) on its own, without a local backend.</p><li><a href="../README.md#routing-and-failover">Routing and Failover</a> - the general</li><p>  <code>Router</code>/<code>EnergySiteRouter</code> dispatch and failover semantics.</p><li><code>aiopowerwall</code>&#x27;s own README for local-only concerns: gateway pairing</li><p>  (breaker toggle / auto-verify), backup-reserve and SoC scaling, and   multi-Powerwall leader/follower behavior.</p></ul>
</article>
</main>
</body>
</html>

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ tesla_fleet_api/tesla/energysite.py:127 - This docstring still tells callers to poll list_authorized_clients for VERIFIED, which contradicts the new local-control guidance that cloud polling is only secondary/best-effort because the endpoint can return bare null. Update this to point callers at signed local-read verification, or at least qualify cloud polling as non-authoritative.

🔧 Fix: Align energy pairing verification docs
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • uv run pytest tests/test_teslemetry_authorized_clients.py tests/test_router.py::EnergySiteRouterTests
  • Generated /tmp/no-mistakes-evidence/01KXCCGMKSM7AHK3F5HK9GSQ8N/energy-local-control-rendered.html from docs/energy_local_control.md and checked the revised verification guidance/order in the rendered artifact.
  • uv run pytest tests
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

firstmate crewmate added 8 commits July 13, 2026 09:34
The RSA key pairing (Tesla.get_rsa_private_key, EnergySite.add_authorized_client)
and EnergySiteRouter composition with aiopowerwall's local signed transport
were fully shipped but undocumented, which led a recent capability audit to
mischaracterize the surface as dormant. Adds docs/energy_local_control.md
covering the end-to-end flow and cross-links it from the README and the
existing energy sites doc.
Polling the cloud list_authorized_clients endpoint alone is unreliable
(Tesla currently returns a bare-null-200 body there, and that may recur).
Make the primary verification method attempting a signed local read
through aiopowerwall (retried with backoff, since PowerwallAuthenticationError
before verification is expected) and demote the cloud poll to a secondary,
best-effort check.
@Bre77 Bre77 changed the title docs(energy): document local energy control pairing docs(energy): document local control pairing Jul 13, 2026
@Bre77 Bre77 merged commit 4537aff into main Jul 13, 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