Skip to content

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

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

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

Conversation

@mgazza

@mgazza mgazza commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

This replaces the original service-hook control path for the TeslemetryAPI component (apps/predbat/teslemetry.py, Tesla Powerwall support over the Teslemetry / Tesla Fleet API) with a fox-style window control plane. Predbat now programs Tesla Powerwall exactly the way it programs a Fox inverter — through a TESLA inverter type and virtual schedule entities driven directly by inverter.py — with no HA service hooks anywhere in the control path.

Window control plane

  • New "TESLA" entry in INVERTER_DEF (apps/predbat/config.py). Notably support_discharge_freeze: False (the Powerwall has no discharge-freeze/hold state — see below), support_charge_freeze: True, output_charge_control: "none" (no rate control, on/off + target SOC only), time_button_press: True (writes are committed atomically rather than instantly on entity change), and can_span_midnight: False.
  • The component publishes the standard Fox-style schedule entities — charge/discharge window start/end time selects, charge/discharge target-SOC numbers, per-window enable switches, a backup-reserve number, and an atomic write button (schedule_write) that commits a staged edit to the live schedule in one step. inverter.py reads and writes these exactly as it does for a Fox inverter; the component itself never talks to the HA service-call layer.

Scheduler emulator

The Powerwall has no native on-device scheduler to hand a plan to (unlike Fox Cloud, which stores its own windows). To emulate one, run() evaluates the committed schedule + wall clock + live SOC every cycle (evaluate_schedule) into a single device-state tuple:

  • tariff mode (normal / export_now)
  • export rule (never / pv_only / battery_ok)
  • grid-charging on/off
  • backup reserve %
  • operation mode (self_consumption / autonomous / backup)

That tuple is asserted onto the real device via assert_device_state, in a fixed order (tariff first, mode last — the order proven safe by the original template). Each of the five writes is deduplicated: a signature of the last successfully confirmed value is cached per control, and a write is skipped unless the desired value has actually changed. This keeps Teslemetry command-credit spend flat under steady state. If a write fails, the cache is left stale (it's only updated on success), so the identical desired state is simply re-sent and retried on the next 5-minute cycle — no separate retry loop needed. All of this is gated on set_read_only, so a read-only Predbat instance never issues a device command.

One behavioural change from the original template: the idle state (no active charge or discharge window) now sets export rule to pv_only rather than never. The old template's never would curtail excess solar generation while idle; pv_only lets PV export through while still blocking grid export.

Persistence

The committed schedule is saved/loaded through the Storage component (("teslemetry", "schedule")), so it survives an add-on restart mid-plan — the Powerwall can't be asked to hand the schedule back, so Predbat has to remember it.

Automatic configuration

Setting teslemetry_automatic: True now auto-wires the inverter args for you. End-user setup is down to three apps.yaml keys — teslemetry_key, teslemetry_site_id, teslemetry_automatic (an optional fourth, teslemetry_base_url, only needs setting for a non-default Fleet API endpoint). templates/tesla_powerwall.yaml has been rewritten from the old manual HA-integration template to this component-based form, and docs/components.md / docs/inverter-setup.md document the new setup and behaviour (including the export-freeze and idle-pv_only notes above).

Why support_discharge_freeze: False

The Powerwall cannot pin SOC while continuing to export PV — the hardware's self_consumption/autonomous discharge modes let backup reserve absorb solar rather than hold state at a fixed level, so there is no device mode that reproduces Predbat's discharge-freeze semantics. Rather than special-casing this in the Tesla component, the INVERTER_DEF flag is set False and the existing generic gate in execute.py (used by any inverter type lacking the feature) auto-disables set_export_freeze/set_export_freeze_only when the first enabled inverter doesn't support it.

Testing

  • tests/test_teslemetry.py: 78 tests (plus the unit_test.py registry entry point) covering the data path, transport robustness, control-entity handlers, tariff/export-trick building, boot reconciliation, write-on-change dedupe, the scheduler emulator, schedule entities + persistence, device-state assertion, and automatic_config wiring.
  • ./run_all --quick: all 131 test groups pass, including teslemetry and multi_car_iog_load_slots_regression (previously the one known pre-existing flake on this branch — passed clean on this run).
  • Pre-commit (black/ruff/cspell en-gb/markdownlint) 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.
  • The scheduler emulator's device-state mapping (tariff/export-rule/mode transitions) is unit-tested against mocked responses only — its behaviour needs validating against live hardware during the beta pilot.

Compatibility notes

  • Legacy users of the old template's inline inverter: dict keep their service-hook behaviour — the inline dict overlays the new built-in TESLA type, and the generic *_service args still exist; unset keys now default to the TESLA entry's values (behaviour-neutral in practice).
  • The five diagnostic control entities are live mirrors of the emulator's asserted state, so manual changes to them are transient unless set_read_only is enabled.

🤖 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.

pre-commit-ci-lite Bot and others added 15 commits July 6, 2026 23:15
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ack live SOC

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate each cycle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r type

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Alphabetical re-sort triggered by the file-contents-sorter pre-commit
hook ahead of final verification for the Teslemetry window control
plane work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ional auto-config args (review)

Addresses three review findings on the Tesla Powerwall Teslemetry component:
- reconcile_on_start's boot check could permanently gate the scheduler emulator off if the
  device's tariff_rate response never yields a usable code; bound it to RECONCILE_MAX_ATTEMPTS
  retries, after which run() force-latches reconcile_done with a one-time warning.
- get_minutes_now() now always reads the live wall clock instead of preferring base.minutes_now,
  which only refreshes on Predbat's 5-minute cycle and could freeze window enforcement.
- automatic_config() only wires battery_rate_max/inverter_limit args when fetch_site_info
  actually published those sensors, so a site missing nameplate_power/max_site_meter_power_ac
  no longer gets args pointing at entities that never exist.

Also: drop the "(recommended)" Tesla Fleet Integration doc heading that contradicted the new
Teslemetry-first recommendation, document that the five diagnostic control entities are
transient mirrors of the emulator state, and de-duplicate a case-insensitive "teslemetry" entry
in the cspell workspace dictionary that was making the file-contents-sorter pre-commit hook
non-deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plate

- Restore templates/tesla_powerwall.yaml to the original manual HA-integration
  config (service hooks) instead of repurposing it for the component
- Add templates/teslemetry.yaml for the beta component path
- Mark the Teslemetry component as beta / not-yet-recommended in components.md
  and inverter-setup.md
- Add a Teslemetry component (beta) row to the inverter table
- gitignore docs/superpowers/ planning artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fault)

Adds a MockBase, test_teslemetry_api() and argparse main() to teslemetry.py,
matching the fox.py / gecloud.py CLI test pattern. Run with:

  python teslemetry.py --key <token> --site-id <id>

By default the harness runs read-only: it polls the Powerwall and prints/
publishes all entities and status but sends no control commands (the scheduler
emulator and crash-recovery writes are gated on set_read_only). Pass --control
to allow commands to be sent. MockBase.get_arg consults args so the read-only
default actually gates writes; covered by a unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The standalone test harness printed the run result and exited 0 even when
run() returned False, so an invalid token (401) or unreachable API looked
like a pass. test_teslemetry_api now returns a success bool - False on an
auth failure or unsuccessful run, printing a clear FAILED message - and
main() exits with that status. Regression test covers both outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
springfall2008 and others added 9 commits July 18, 2026 16:28
site_id is now optional and acts as a filter over the sites discovered from
GET /api/1/products rather than a hardcoded target. When omitted the account's
sites are used; when given (string or list) it reduces the discovered set. For
now a single site is supported - the first matching candidate is used and any
others are logged (full multi-site is a follow-up). run() resolves the site
before polling and retries discovery until it succeeds; the CLI --site-id is
optional. Tests cover filter/first-match/no-match and run-time discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… data

Fixes found running against a real Powerwall 3:
- soc_max: source from live_status total_pack_energy (PW3 site_info omits
  nameplate_energy); decouple the site_info latch from soc_max so automatic
  config is no longer blocked, and gate auto-config on soc_max-present after
  the live poll (reordered in run()).
- inverter_limit: max_site_meter_power_ac is often an 'unlimited' sentinel
  (e.g. 1e9); clamp absurd/absent values and fall back to nameplate_power.
- soc: round to 2 decimal places.
- tariff: there is no GET /tariff_rate endpoint (404); read the current
  tariff from site_info (tariff_content_v2) instead.
- Log every raw API response (Info) so live output can be cross-checked
  against the component's parsing during the beta.

Tests updated for the new site_info/tariff sourcing; adds soc-rounding,
inverter-limit-sentinel, auto-config-from-live-soc_max and raw-response-log
coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stimate

Real PW3 (firmware 26.18.1) exposes no battery-capacity field anywhere -
neither total_pack_energy/energy_left in live_status nor nameplate_energy in
site_info - so soc_max was never published. Resolve capacity in priority
order and prefer a real value over an estimate:
  1. live_status total_pack_energy
  2. live_status energy_left / percentage_charged
  3. site_info nameplate_energy
  4. site_info battery_count x ~13.5 kWh/unit (estimate, logged; overridable)
A real value later upgrades a published estimate. Also read the current
tariff from tariff_content (v1) as well as tariff_content_v2 - the observed
device carries the real code under v1.

On the user's logged data this now yields soc_max=13.5 and fires auto-config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kWh)

When capacity must be estimated from battery_count, tell Powerwall 1 apart
from Powerwall 2/3 by per-unit nameplate power (site nameplate_power /
battery_count): a PW1's ~3.3 kW inverter is far below PW2 (~5 kW) / PW3
(~11.5 kW), so <=3500 W/unit -> 6.4 kWh, otherwise 13.5 kWh, scaled by
battery_count. The gateway part_name is logged for confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…daily totals

The calendar_history?period=day response is large (~160 KB, ~200 five-minute
buckets plus SmartBreakerEnergyLogs) yet we only use it to compute four daily
kWh totals. energy_history?kind=energy&period=day aggregates to the same
totals from far fewer buckets and without the breaker logs - the same endpoint
the Home Assistant Teslemetry integration uses. Field names and summing are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ess often

energy_history 404s on Teslemetry: the library's energy_history() is just a
wrapper around calendar_history?kind=energy&period=day (same HTTP endpoint), so
there is no smaller 'totals only' route. Revert to calendar_history (known
good) and instead cut the repeated ~150 KB downloads by raising the energy
poll interval from 5 to 15 minutes - the values are slow daily counters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bug log

- Revert ENERGY_POLL_SECONDS to 300: Predbat derives 5-minute energy buckets
  from the daily cumulative totals, so they must be refreshed every 5 minutes.
- Summarise the raw-response log: the calendar_history time_series (and
  SmartBreakerEnergyLogs) and the site_info tariff_content/tariff_content_v2
  blocks are replaced with a short placeholder (entry count / tariff code), so
  the log shows which endpoint returned what without dumping ~150 KB of history
  or a full ToU rate table (calendar_history 167 KB -> 194 B, site_info
  29 KB -> 2.6 KB in practice). Small responses pass through unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-only works

The MockBase.get_arg had reverted to returning the default, ignoring self.args.
That made _is_read_only() read set_read_only as False in the standalone CLI
harness, so the scheduler emulator issued device-write commands (operation,
backup, grid_import_export, time_of_use_settings) even in the default READ-ONLY
mode. Restore get_arg to consult self.args (keeping the state-printing set_arg);
the existing guard test now passes and a read-only run sends zero control POSTs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants