Skip to content

Add TeslemetryAPI component — Tesla Powerwall control via Teslemetry/Fleet API#4177

Open
mgazza wants to merge 14 commits into
mainfrom
feat/tesla-powerwall-teslemetry
Open

Add TeslemetryAPI component — Tesla Powerwall control via Teslemetry/Fleet API#4177
mgazza wants to merge 14 commits into
mainfrom
feat/tesla-powerwall-teslemetry

Conversation

@mgazza

@mgazza mgazza commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

New TeslemetryAPI component (apps/predbat/teslemetry.py): Tesla Powerwall support over the Teslemetry REST API (which mirrors Tesla Fleet API paths — the base_url is configurable so a direct Fleet API connection works with zero component changes). Ports the proven templates/tesla_powerwall.yaml control semantics (operation mode / backup reserve / grid-charging / export rule / export tariff-trick) from HA service hooks onto component virtual entities, driven by the existing *_service hook loopback.

  • Data path: polls live_status (120 s), site_info (capacity → soc_max; seeds operation-mode/reserve entity states), calendar_history (daily kWh) → publishes predbat_teslemetry_* sensors. 401/403 latches an auth-failed guard (recovery via live_status probe only); 429 exponential backoff.
  • Control path: five virtual entities (operation_mode, backup_reserve, allow_charging_from_grid, allow_export, tariff_mode) with write-only-on-success semantics — a failed REST command leaves entity state untouched so the repeat: true hooks re-assert next cycle.
  • Export tariff-trick: tariff_mode=export_now posts a synthetic ToU tariff (current 30-min window ON_PEAK, sell price max(0.50, 2× live export rate)); correct circle-complement off-peak periods including midnight wrap (property-tested: exact partition of the day for all 48 slots). normal restores a flat tariff from the customer's live rates.
  • Boot reconciliation: reads the device's current tariff (GET /tariff_rate) and, if our PREDBAT-EXPORT-NOW marker is present (pod died mid-export), restores the normal tariff + disables export — gated on the real set_read_only config via get_arg.
  • Registration: components.py entry (event_filter: predbat_teslemetry_) + APPS_SCHEMA keys teslemetry_key/teslemetry_site_id/teslemetry_base_url.

Companion SaaS PR: Predictive-Cloud-Ltd/predbat-saas feat/tesla-powerwall-teslemetry (onboarding + config generation). Part of Predictive-Cloud-Ltd/predbat-saas#1346.

Testing

  • 34 unit tests in tests/test_teslemetry.py (registered in unit_test.py): field mapping/kWh conversions, real _request status branching (401 latch, 429 retry+backoff, recovery), run() bool contract, all five command handlers incl. failure paths, tariff builder (incl. midnight-wrap partition invariant and sell clamp), reconcile (marker found / absent / read-failed / read-only / boot-default gating).
  • ./run_all --quick: 122 groups pass (1 pre-existing unrelated failure multi_car_iog_load_slots_regression, verified pre-existing via stash-baseline).
  • Pre-commit (black/ruff/interrogate 100%/cspell en-gb) clean.

Known follow-ups

  • GET /tariff_rate response shape is inferred from docs (defensive code-walker parse; fails safe) — to be validated on live PW3 hardware during the beta pilot.
  • Auth-failed steady state logs a warning per minute (deliberate: component is genuinely unhealthy); may want throttling before long soak.

🤖 Generated with Claude Code

mgazza and others added 10 commits July 2, 2026 22:55
Implement control path for Tesla Powerwall integration:
- Add site_info_done latch to ensure soc_max publishes eventually
- Register virtual control entities: operation_mode, backup_reserve, allow_charging_from_grid, allow_export, tariff_mode
- Implement command handlers: set_operation_mode, set_backup_reserve, set_grid_charging, set_export_rule
- Add set_tariff stub (completed in Task 6)
- Implement event handler overrides: select_event, number_event, switch_event
- Add 6 control tests + 1 latch test (16 passing tests total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement Task 6 of Tesla Powerwall integration: tariff builder, export-now trick,
and boot reconciliation.

- Add EXPORT_SELL_RATE constant and current_rates() to read from base.rate_import/export
- Implement build_tariff(mode, now) with export_now (current 30-min window ON_PEAK) and
  normal (flat) modes, returning tariff_content_v2 dict
- Replace set_tariff() stub with real implementation POSTing /time_of_use_settings
- Add reconcile_on_start() to restore safe state if previous run died mid-export
- Wire reconcile_on_start() into run() after site_info latch

All 5 new tests pass (build tariff, set tariff, reconcile); regression on ge_cloud
also passes (66 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review findings on the export tariff-trick:

- CRITICAL: export_now windows crossing midnight (e.g. 23:30-00:30) produced
  overlapping SUPER_OFF_PEAK periods covering the full day. The complement is now
  a proper circle complement of [start, end): a single wrapped segment when the
  window crosses midnight, two segments otherwise.
- IMPORTANT: ON_PEAK sell is now clamped to max(EXPORT_SELL_RATE, 2x live export
  rate) so the trick cannot silently invert when the live export rate exceeds 0.50.
- MINOR: buy-side ON_PEAK mirrors the high sell rate to discourage grid-charging
  during the export window.
- Tests: midnight-wrap (23:40) and exact-midnight (23:10) cases with a
  partition-of-day assertion (no overlap, no gap), sell-clamp with a live 0.60
  export rate, and a reconcile_on_start partial-failure pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registers TeslemetryAPI in COMPONENT_LIST (phase 1, event_filter
predbat_teslemetry_) and adds teslemetry_key/teslemetry_site_id/
teslemetry_base_url to APPS_SCHEMA, placed alongside the fox_key
inverter block (config.py has no deye_key entry to anchor on).
…ation

reconcile_on_start previously read the local tariff_mode entity, which
register_control_entities() unconditionally reseeds to "normal" on every
boot before run(first=True) fires - making the export_now recovery path
dead code (a pod restart mid-export could never self-heal). It now reads
the Powerwall's actual tariff via GET tariff_rate and walks the response
for a "code" matching build_tariff("export_now"), respecting read-only
mode. _command also treated any parsed 2xx body as success; it now fails
on an application-level "error" key or response.code >= 400. fetch_site_info
seeds the operation_mode/backup_reserve entity states from device data
instead of leaving them at hardcoded defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_is_read_only() read base.set_read_only directly, but the PredBat
constructor defaults that attribute to True (predbat.py:504) and it is
only refreshed from config in the fetch cycle (fetch.py:2401), which
runs AFTER phase-1 components start - so at reconcile_on_start time the
attribute was always True and the recovery write was unreachable again,
with a misleading read-only log. load_user_config(load_config=True)
runs before phase-1 start (predbat.py:1574 vs 1577), so reading via
get_arg("set_read_only", False) returns the real configured value.
Also split get_current_tariff_code's failure logging: "read failed"
(no response) vs "no tariff code" (response without a code key).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oc_max retry, calendar_history query, control attrs, number guard

Fixes six code-review findings, each with a regression test confirmed red before/green after:

- build_tariff now derives `now` from the base's local time (base.now_utc.astimezone(base.local_tz))
  instead of raw UTC, so the export_now ON_PEAK window lands on the Powerwall's actual local
  wall-clock boundary instead of being off by the site's UTC offset.
- run() gates reconcile_on_start() on a new `reconcile_done` latch (mirroring site_info_done) instead
  of the `first` flag, so a boot that 401s (consuming `first`) and later recovers still reconciles a
  device stuck exporting. reconcile_on_start() now returns whether it actually got a usable tariff
  read, so the latch only sets once that happened.
- fetch_site_info() only returns True once soc_max was actually published; when nameplate_energy is
  absent/zero it still seeds the other control-entity fields but returns False, so site_info_done
  stays False and it retries.
- fetch_energy_today() now requests calendar_history with kind=energy&period=day, which the
  Fleet/Teslemetry endpoint requires.
- All control-entity writes (select/number/switch events, reconcile's restore, site_info seeding) now
  route through a new publish_control() helper that re-applies the options/min/max/friendly_name
  register_control_entities recorded at init, instead of wiping them via a bare set_state_wrapper call.
- number_event() guards the int(float(value)) conversion against a null value (e.g. from a
  number.increment/decrement service call) instead of raising, matching gateway.py's pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mgazza

mgazza commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed findings from a follow-up review pass in 8443eac3:

  1. Export tariff window was built in UTC. build_tariff used datetime.now(timezone.utc), but a Powerwall applies tou_periods in the site's local timezone — the same local clock Predbat schedules in. During BST/CEST the ON_PEAK window landed an hour (or two) off, so autonomous-mode export never triggered on any non-UTC site. Now derives site-local wall-clock via base.now_utc.astimezone(base.local_tz).
  2. Boot reconcile could be skipped. A transient 401 on the first cycle consumed the first flag before reconcile_on_start ran, so a pod that died mid-export stayed exporting after the token recovered. Reconcile is now gated on a separate reconcile_done latch that only sets after a usable tariff read.
  3. soc_max never retried if site_info returned without nameplate_energyfetch_site_info now returns False in that case so it re-fetches next cycle.
  4. calendar_history was missing required params — now requests ?kind=energy&period=day.
  5. Control-entity writes wiped their own attributes. set_state_wrapper(entity, value) with empty attrs dropped the select options / number min-max set at registration. A new publish_control() helper re-applies the registered attributes on every write.
  6. number_event guarded against a null value from number.increment/decrement (was raising TypeError out of the dispatch loop).

Tests 34 → 42; ge_cloud sibling suite unchanged at 66/66.

pre-commit-ci-lite Bot and others added 3 commits July 6, 2026 11:04
…slemetry command-credit spend

Route the 5 control writes (operation_mode, backup_reserve, grid_charging,
export_rule, tariff) through a per-key _last_sent signature cache so a POST
only fires when the value actually changed, instead of every 5-minute
loopback cycle from the repeat:true service hooks. The cache is only
updated on a confirmed-successful send, so a failed command still retries
next cycle, and fetch_site_info refreshes operation_mode/backup_reserve to
the device's actual state so external drift (e.g. changed in the Tesla
app) is detected and re-asserted. reconcile_on_start forces its tariff and
export-rule recovery writes so a future cache-preseed can never make
recovery look like a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mgazza

mgazza commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Command-credit optimisation in 1026e6d8 (+ test/doc coverage in 9accebd8):

The control hooks re-assert every ~5-min cycle (repeat: true) and each was POSTing to Teslemetry unconditionally — ~20k commands/month ≈ £25–30/mo per site in Tesla Fleet API command credits. Added write-on-change dedupe in the component:

  • New _apply_command(key, signature, sender) skips the POST when the value matches the last confirmed-successful send. The cache updates only on success, so a failed/dropped command still retries next cycle.
  • All 5 controls routed through it; the tariff is keyed on its serialised built body, so the export ToU window still re-sends when it advances or the sell price changes (~24–48×/day) — just not every idle cycle.
  • Drift correction: fetch_site_info refreshes the cache from the device's actual mode/reserve, so an external change made in the Tesla app is re-asserted.

Net: ~£25–30/mo → ~£2–4/mo per site. Failure-retry and drift both covered by tests; suite 42 → 53.

Known limitation (documented in-code, accepted for the beta pilot): the device-drift refresh is boot-only for mode/reserve, and grid_charging/export_rule aren't device-drift-refreshed at all (site_info doesn't expose their state) — so those self-heal on Predbat's next transition of the control rather than within a cycle. Full periodic device-state reconciliation is a pilot follow-up, to be built against verified PW3 response shapes.

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