diff --git a/AGENTS.md b/AGENTS.md index c475a56..d6fd813 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,229 +1,100 @@ # Teslemetry TypeScript Monorepo -## Overview +pnpm workspaces + changesets. `packages/api` is the official Teslemetry SDK; `packages/node-red-contrib-teslemetry`, `packages/n8n-nodes-teslemetry`, and `packages/homebridge-teslemetry` are platform integrations that depend on it via `"@teslemetry/api": "workspace:*"`, so they always build against the local SDK. -This is the **Teslemetry TypeScript monorepo** containing the official TypeScript/JavaScript SDK and multiple platform integrations for the Teslemetry API. The monorepo uses **pnpm workspaces** for package management and **changesets** for versioning and publishing. +For each package's purpose, structure, exported surface, node/operation lists, and version, read its own `package.json`/`README.md` and source tree - that detail goes stale here. -**Primary Purpose**: Provide a comprehensive ecosystem for Tesla vehicle and energy site control across multiple automation and smart home platforms. +The ioBroker adapter lives in its own repo, [Teslemetry/ioBroker.teslemetry](https://github.com/Teslemetry/ioBroker.teslemetry). -## Monorepo Structure +## Development -``` -typescript-teslemetry/ -├── packages/ -│ ├── api/ # Core TypeScript/JavaScript SDK -│ ├── node-red-contrib-teslemetry/ # Node-RED integration -│ ├── n8n-nodes-teslemetry/ # n8n workflow integration -│ └── homebridge-teslemetry/ # Homebridge plugin (published to npm) -├── pnpm-workspace.yaml # Workspace configuration -├── tsconfig.json # Root TypeScript config -├── package.json # Monorepo root package -├── RELEASE.md # Release process documentation -└── .github/workflows/publish.yml # Automated CI/CD -``` - -See each package's own `package.json`/`README.md` for its purpose, structure, and current version - don't rely on this file for per-package detail, which goes stale quickly. - -The ioBroker adapter (`iobroker.teslemetry`) lives in its own repo, [Teslemetry/ioBroker.teslemetry](https://github.com/Teslemetry/ioBroker.teslemetry) - not in this monorepo. - -All integration packages (`node-red`, `n8n`, `homebridge-teslemetry`) depend on `@teslemetry/api` via `"@teslemetry/api": "workspace:*"`, so they always build against the local SDK during development. - -## Packages - -### 1. `@teslemetry/api` - Core SDK - -**Location**: `packages/api/` - -**Purpose**: Official TypeScript/JavaScript client library for the Teslemetry API - vehicle control, energy site management, real-time SSE streaming, automatic region detection (NA/EU), dual ESM/CJS output. - -**Main Source Files**: -- `src/Teslemetry.ts` - Main entry point -- `src/TeslemetryApi.ts` - REST API wrapper -- `src/TeslemetryVehicleApi.ts` - Vehicle operations -- `src/TeslemetryEnergyApi.ts` - Energy site operations -- `src/TeslemetryStream.ts` - SSE streaming base -- `src/TeslemetryVehicleStream.ts` - Vehicle-specific streaming -- `src/TeslemetryUserApi.ts` - User account operations -- `src/TeslemetryChargingApi.ts` - Charging-specific operations -- `src/client/` - Auto-generated OpenAPI client (don't hand-edit) - -**Code Generation**: -- Uses `@hey-api/openapi-ts` to generate the client from the OpenAPI spec. Run: `pnpm --filter @teslemetry/api client` (the package.json script is named `client`, not `generate`). -- `src/client/sdk.gen.ts` often already has functions for endpoints that `TeslemetryVehicleApi.ts`/`TeslemetryEnergyApi.ts` haven't wrapped yet - grep it for the endpoint before assuming a capability gap needs a spec regen; usually it's just a new hand-written method calling the existing generated function. -- `@hey-api/openapi-ts` stable releases crash against `typescript@7.x` because they call into the `typescript` package's compiler-API enums at runtime, which TS7's native rewrite doesn't expose - hence the `0.0.0-next-*` pin on `@hey-api/openapi-ts` in `packages/api/package.json` instead of a stable semver range. Bump to a stable release once `npm view @hey-api/openapi-ts@next dependencies` shows `typescript` has dropped out of a *stable* tag's dependency tree. -- The `input:` in `openapi-ts.config.ts` fetches the live `api.teslemetry.com/openapi.yaml`, not the api repo's committed `openapi.json` - the two can briefly diverge around a deploy, and the live endpoint can be *ahead* of the api repo's committed snapshot. If regenerating to pick up a specific just-merged api-repo change, prefer fetching that repo's `openapi.json` from `main` over trusting the live endpoint's current deploy state. -- CI's "Verify API client codegen toolchain" step (`.github/workflows/reusable-ci.yml`) regenerates into a throwaway temp directory on every PR to catch toolchain breaks at PR time - it does not diff against the committed `src/client/`, since live-spec drift is expected and not itself a bug. - -**Gotcha**: every Teslemetry API request carries the access token as a `?token=...` query parameter, so `response.url`/`request.url` on the generated client's `Client` (`src/client/client/*.ts`) is credential-bearing - `Teslemetry.ts`'s response interceptor logs only `new URL(response.url).pathname`, never the full URL. Any future logging, error-reporting, or telemetry code touching a request/response object in `packages/api` must strip the query string (not just a named param) before it reaches a consumer-wired `logger`, since consumers like the Homey app forward `debug`-level logs into user-visible diagnostics. - -**Gotcha**: `getTariffPeriods`/`TariffContentV2` (`src/tariff.ts`) are bundled in from the published `tesla-fleet-api` npm package rather than hand-ported - tsdown externalizes `dependencies`/`peerDependencies` by default but bundles `devDependencies`, so `tesla-fleet-api` is a `devDependency` here specifically to get inlined into `dist/` with no runtime dependency on it. `src/tariff.ts` deep-imports `tesla-fleet-api/dist/tariff.js` and `tesla-fleet-api/dist/types/site_info.js` directly (not the package root) so tree-shaking never has to prove the rest of that package's vehicle/signing/commands surface is side-effect-free. `tsdown.config.ts`'s `deps.onlyBundle: ["tesla-fleet-api"]` documents that inlining as intentional; adding another bundled devDependency needs a matching entry there. `useTeslaModel()` (`src/Teslemetry.ts`) follows the same deep-import pattern, importing `Models` from `tesla-fleet-api/dist/types/vehicle.js` rather than maintaining a local copy - check that leaf (not the package root, and not `dist/vehicle.js`'s `Vehicle` class, which pulls in the whole signing/protocol graph) before adding a new hand-maintained constant the library may already export. - -### 2. `node-red-contrib-teslemetry` - Node-RED Integration - -**Location**: `packages/node-red-contrib-teslemetry/` - -**Purpose**: Node-RED nodes for Tesla vehicle and energy site automation. - -**Nodes**: -1. **teslemetry-config** - Configuration node (stores API credentials) -2. **teslemetry-vehicle-command** - Vehicle commands and data retrieval -3. **teslemetry-energy-command** - Energy site commands (REST) -4. **teslemetry-energy-history** - Energy site calendar/telemetry history (REST) -5. **teslemetry-event** - Real-time vehicle event listener (SSE) -6. **teslemetry-signal** - Real-time single vehicle signal field listener (SSE) -7. **teslemetry-energy-event** - Real-time energy site event listener (SSE: `live_status`/`site_info`/`tariff_content_v2`/`energy_totals`) -8. **teslemetry-wall-connector** - Splits a `live_status` payload's `wall_connectors[]` into one message per connector, keyed by DIN, with an optional DIN filter - -**Structure**: each node has a TypeScript file (`.ts`) and HTML UI file (`.html`); `src/shared.ts` holds shared utilities, `src/validation.ts` input validation. - -**Build**: `pnpm --filter node-red-contrib-teslemetry build` runs `tsdown && cp src/nodes/*.html dist/nodes/` - HTML files must be copied manually, tsdown won't do it. - -**Gotcha**: `teslemetry-signal`'s field dropdown is populated from `teslemetry.api.getFields()` at edit time (`teslemetry-config.ts`'s `/teslemetry/fields` admin route), i.e. the live API's field registry, not a hand-maintained list - any new vehicle telemetry field the backend exposes is automatically selectable with zero code changes here. Combined with `teslemetry-event`/`teslemetry-energy-event` streaming whole raw payloads, most Homey/Homebridge-style "new capability" work (per-field mapping, units, gating) has no equivalent here: only genuinely new SDK *commands* (wire into `teslemetry-vehicle-command`/`teslemetry-energy-command`'s switch-case) or missing *streams* are real gaps in this package. Threshold-crossing and lifecycle-transition logic (Homey's Flow trigger cards) has no dedicated node either - it's expected to be composed downstream with core Node-RED `switch`/`change`/`function` nodes over the raw stream, not reimplemented here. - -**Gotcha**: `TeslemetryStream`'s reconnect loop (`packages/api/src/TeslemetryStream.ts`) gives up permanently after two consecutive `auth_failure`s (`this.active = false`) - a bad/expired token doesn't get the exponential-backoff treatment ordinary `stream_error`s do, and nothing resumes the stream until something outside the SDK calls `connect()` again. `teslemetry-event`/`teslemetry-signal`/`teslemetry-energy-event` share this recovery logic (and the connect/disconnect/stream_error/auth_failure status wiring) through `attachStreamStatus()` in `src/shared.ts` rather than each hand-rolling it - extend that helper for new streaming nodes instead of duplicating the listener wiring. - -**Gotcha**: `teslemetry-config.ts`'s initial `createProducts()` fetch (populates the vehicle/energy-site dropdowns and gates `hasInstanceError()`) self-heals via `createProductsFetcher()` - on failure it retries on a fixed timer and clears `Instance.error` on the next success, rather than caching the first failure for the node's lifetime. A corrected token still requires a redeploy (it's baked into the `Teslemetry` client at construction), but a transient fetch failure recovers on its own without one. - -The config node's editor has a "Test credentials" button (`POST /teslemetry/test-credentials`) that validates a token pre-save via a throwaway `Teslemetry` client and `teslemetry.api.test()` (the lightest-weight authenticated call, `GET /api/test`) - `testCredentials()` in `teslemetry-config.ts` captures the response status via a client response interceptor to distinguish a 401/403 auth failure from any other error, since the generated client's thrown errors don't carry `.status` themselves. - -**Local testing**: ```bash -cd packages/node-red-contrib-teslemetry -pnpm build -pnpm link --global - -# In another terminal -cd ~/.node-red -pnpm link --global node-red-contrib-teslemetry -node-red +pnpm install +pnpm build # or --filter build +pnpm --filter test # tsx --test test/*.test.ts (Node's runner) +pnpm --filter @teslemetry/api client # regenerate the OpenAPI client +pnpm lint # oxlint, single root invocation (pnpm lint:fix to fix) +pnpm -r --no-bail tsc # typecheck every package ``` -### 3. `n8n-nodes-teslemetry` - n8n Integration +To make a change: branch, edit, `pnpm build`, `pnpm changeset`, commit (including `.changeset/`), open a PR. Release mechanics: `RELEASE.md`. -**Location**: `packages/n8n-nodes-teslemetry/` +**Toolchain**: **tsdown** (rolldown) builds; its `rolldown-plugin-dts` dependency must support the installed `typescript` major or declaration emit breaks - it, not tsdown, is the compiler-API consumer. **TypeScript 7.x**: the config surface dropped `baseUrl` and the `node`/`node10` `moduleResolution` value, and defaults `types` to `[]` instead of auto-including all `@types/*` - a tsconfig relying on the old implicit behavior needs `"types": ["node"]` added explicitly. -**Purpose**: n8n workflow nodes for Tesla automation. Builds to a single `dist/index.cjs` entry point. +**Lint**: `.oxlintrc.json`'s two `overrides` blocks silence rules that conflict with deliberate patterns, not bugs - `typescript/no-unsafe-declaration-merging` for the SDK's `class X extends EventEmitter` + `declare interface X` typed-emitter pattern, `typescript/no-this-alias` for Node-RED's `const node = this;` idiom. The generated client (`packages/api/src/client/**`) is in `ignorePatterns`: never hand-edit it, never add lint overrides for it. -**Components**: -- `src/credentials/TeslemetryApi.credentials.ts` - API credential definition -- `src/nodes/TeslemetryVehicle.node.ts` - Vehicle operations (climate/seat automation, closures/windows, charging schedules, software update, volume, wake/lock/charge/sentry, data retrieval) -- `src/nodes/TeslemetryEnergy.node.ts` - Energy operations (backup reserve, operation mode, storm mode, grid rules, off-grid EV reserve) -- `src/nodes/TeslemetryTrigger.node.ts` - Event triggers: vehicle SSE events (including a generic per-field Signal listener) and energy site SSE events (live status, site info, tariff content, energy totals) +## `@teslemetry/api` (core SDK) -See each node's `operation`/`event` option list for the exact current set - it grows incrementally and this summary goes stale fast. +Ships dual ESM/CJS. `src/client/` is generated by `@hey-api/openapi-ts`. -**Gotcha**: before adding a new node operation to wrap an SDK command, check whether the capability is already reachable without new code. `TeslemetryVehicleApi`/`TeslemetryEnergyApi` already expose most of the Tesla command surface (grep before assuming a gap, per the `@teslemetry/api` codegen note above), and the Trigger node's generic Signal event type (any field from `teslemetry.api.getFields()`, delivered via `onSignal`) already covers arbitrary read-only vehicle telemetry - unlike Homey/Homebridge, which need a capability/service wired per field, n8n needs no new code to expose a new telemetry field. New operations are only needed for genuinely new **commands** (writes), not for reading data already covered by `vehicleData()`/`getLiveStatus()`/`getSiteInfo()` or the Signal/Energy Site event types. +**Before assuming a capability gap**: grep `src/client/sdk.gen.ts` first - it usually already has a function for the endpoint, and the real work is a hand-written wrapper method in `TeslemetryVehicleApi.ts`/`TeslemetryEnergyApi.ts`, not a spec regen. -**n8n conventions**: node class names must end with `.node.ts`, credential class names with `.credentials.ts`, icons are referenced from `src/`, and the node's `version` field must match `package.json`. +**Codegen**: `@hey-api/openapi-ts` stable releases crash against `typescript@7.x` (they call the `typescript` package's compiler-API enums at runtime, which TS7's native rewrite doesn't expose) - hence the `0.0.0-next-*` pin in `packages/api/package.json`. Move to a stable release once `typescript` has dropped out of a *stable* tag's dependency tree. `openapi-ts.config.ts`'s `input:` fetches the live `api.teslemetry.com/openapi.yaml`, which can be ahead of or behind the api repo's committed `openapi.json`; when regenerating to pick up a specific just-merged api change, fetch that repo's `openapi.json` from `main` instead. CI regenerates into a throwaway temp dir to catch toolchain breaks, and deliberately does not diff against the committed `src/client/` - live-spec drift is expected. -**Local testing**: -```bash -cd packages/n8n-nodes-teslemetry -pnpm build -pnpm link --global +**Credential leak**: every request carries the access token as a `?token=...` query parameter, so `response.url`/`request.url` on the generated client is credential-bearing. `Teslemetry.ts`'s response interceptor logs only `new URL(response.url).pathname`. Any logging, error-reporting, or telemetry code touching a request/response object here must strip the whole query string (not just a named param) before it reaches a consumer-wired `logger` - consumers forward `debug` logs into user-visible diagnostics. -# In another terminal -cd ~/.n8n/nodes -pnpm link --global n8n-nodes-teslemetry -``` +**Bundled devDependency**: tsdown externalizes `dependencies`/`peerDependencies` but bundles `devDependencies`, so `tesla-fleet-api` is a devDependency specifically to be inlined into `dist/` with no runtime dependency on it. `src/tariff.ts` and `useTeslaModel()` (`Teslemetry.ts`) deep-import its leaf modules (`dist/tariff.js`, `dist/types/site_info.js`, `dist/types/vehicle.js`) rather than the package root, so tree-shaking never has to prove the vehicle/signing/commands surface side-effect-free - check those leaves before hand-maintaining a constant the library already exports. `tsdown.config.ts`'s `deps.onlyBundle` documents the inlining; another bundled devDependency needs a matching entry. -### 4. `homebridge-teslemetry` - Homebridge Plugin +**Streaming invariants**: +- The reconnect loop gives up permanently after two consecutive `auth_failure`s (`this.active = false`); a bad/expired token does not get the exponential backoff ordinary `stream_error`s do, and nothing resumes until something outside the SDK calls `connect()` again. +- Energy SSE events disagree on the site identifier: `live_status`/`site_info` carry `site_id`, while `energy_totals` (and any future event sharing that `{id, product_type, topic, url, createdAt, isCache}` shape) carries `id`. `_dispatch()` has one routing block per field name - check the backend schema's actual field when adding an event, don't assume `site_id`. +- Every `on()` override across `TeslemetryStream`/`TeslemetryVehicleStream`/`TeslemetryEnergySiteStream` must call `super.on()` **before** replaying any cached value to the listener; reversing that order silently breaks `once()` (`test/energyStream.test.ts` regression-covers it). +- `src/sseTopics.ts`'s `SSE_TOPICS` mirrors the API repo's `src/lib/sseTopics.ts` allowlist - keep both in sync or a new backend topic can never be selected via `stream.topics`. `SSE_TOPIC_PRESETS` are SDK-only and always expanded to exact wire names client-side; never send a preset name or wildcard over the wire. +- `tariff_content_v2`'s body is `null` for the server's explicit tariff-removal signal, not "no update yet". `EnergySiteCache.tariff_content_v2` distinguishes `null` from `undefined` (never received); replay/cache logic must preserve that, not treat `null` as falsy-skip. -**Location**: `packages/homebridge-teslemetry/` +## `node-red-contrib-teslemetry` -**Purpose**: Exposes vehicles and energy sites as HomeKit accessories via `src/vehicle-services/*` and `src/energy-services/*` service classes, each a thin adapter between a HomeKit `Service`/`Characteristic` and the corresponding `@teslemetry/api` method. +**Build**: `tsdown && cp src/nodes/*.html dist/nodes/` - tsdown does not copy the HTML editor files. -**Publishing note**: ships to npm as `homebridge-teslemetry`, superseding the legacy hand-published package of the same name via a same-name major hard cut (no dual-maintain, no rename). CI publish requires npm trusted publishing to be registered for this package name on npmjs.com (only an npm org owner can do that) - once registered, the changesets flow publishes on the next merge to `main` with no further repo changes needed. +**Where real gaps are**: `teslemetry-signal`'s field dropdown is populated at edit time from `teslemetry.api.getFields()` (the live field registry via the config node's `/teslemetry/fields` admin route), and the event nodes stream whole raw payloads - so a new backend telemetry field is selectable with zero code changes here. Only genuinely new SDK **commands** (wire into the vehicle/energy command nodes' switch-case) or missing **streams** are real gaps. Threshold and lifecycle-transition logic is composed downstream with core `switch`/`change`/`function` nodes, not reimplemented here. -**Gotcha**: HomeKit's `Service`/`Characteristic` static members (e.g. `Service.Lightbulb`, `Characteristic.On`) are concrete subclasses with simpler overridden constructors than the base `Service`/`Characteristic` class hap-nodejs types against - generic helpers that accept "any service/characteristic type" need `WithUUID<{ new (...): T }>`-shaped types (see `*-services/base.ts`), not `typeof Service`/`typeof Characteristic` directly, or `addService`/`getCharacteristic` overload resolution breaks. +**Shared wiring**: streaming nodes get connect/disconnect/stream_error/auth_failure status handling from `attachStreamStatus()` in `src/shared.ts` - extend that helper for a new streaming node instead of duplicating listener wiring. Input validation lives in `src/validation.ts`. -**Gotcha**: `BaseService`/`BaseEnergyService`'s getOrCreate lookup only falls back to a bare `getService(serviceType)`/`addService(new ConcreteService(displayName))` (type-only match) when the caller passes no `subType`. Several sibling services intentionally share a service type with different subTypes - `LockService`/`ChargePortService` both use `Service.LockMechanism`; `ChargeSwitchService`/`DefrostService`/`SentryService`/`WakeService` and `StormWatchService`/`GridChargingService` all use `Service.Switch` - those must always pass a stable unique `subType` string so the base class routes through `getServiceById`/`addService(..., subType)` instead, or they'll collapse onto one shared HAP service (`test/serviceCollision.test.ts`, `test/energyServiceCollision.test.ts` cover this). Services with no natural sibling sharing their type (Information, Battery, Climate) should keep omitting `subType` - HAP pre-creates a default `AccessoryInformation` service on every `Accessory`, and `addService` rejects a second service of the same UUID whose `subtype` is falsy, so forcing a subType there breaks that default-service reuse. +**Config node**: the initial `createProducts()` fetch (populates dropdowns, gates `hasInstanceError()`) self-heals via `createProductsFetcher()` - it retries on a timer and clears `Instance.error` on the next success rather than caching the first failure for the node's lifetime. A corrected *token* still needs a redeploy, since it is baked into the `Teslemetry` client at construction. The editor's "Test credentials" button (`POST /teslemetry/test-credentials`) validates a token pre-save via a throwaway client and `teslemetry.api.test()`; `testCredentials()` captures the status through a response interceptor because the generated client's thrown errors carry no `.status`. -**Testing**: `test/fakePlatform.ts`, `test/fakeVehicle.ts`, and `test/fakeEnergySite.ts` provide fakes for the Homebridge/HAP/Teslemetry-SDK objects the services depend on - real hap-nodejs `Service`/`Characteristic` classes are used (not mocks) so characteristic get/set wiring behaves as it does at runtime, driven via `handleSetRequest`/`handleGetRequest` rather than `setValue` (which fires the handler asynchronously with no return value to await). `vehicle.api`/`site.api` are plain stubs, not real `@teslemetry/api` instances, to keep tests free of network I/O. +**Local testing**: `pnpm build && pnpm link --global`, then `pnpm link --global node-red-contrib-teslemetry` from `~/.node-red`. -**Gotcha**: `vehicle-services/climate.ts` maps `CurrentHeatingCoolingState` off the `HvacPower` signal (the schema's actual system power enum: Off/On/Precondition/OverheatProtect), not `HvacACEnabled` (narrowly "is the AC compressor running") - heating can be active with the AC compressor off. `HvacACEnabled` is still subscribed, but only to pick HEAT vs COOL once `HvacPower` already says the system is on - when adding a new signal-driven characteristic, check `packages/api/src/client/types.gen.ts` for whether a narrowly-scoped boolean flag or a proper state enum is the right source of truth. +## `n8n-nodes-teslemetry` -**Gotcha**: `energy-services/*` never touch `site.sse` directly - they subscribe to `site.api.on("siteInfo"|"liveStatus", ...)`, an event bus whose REST-response envelope shape (`{ response: {...} }`) they all destructure from. `EnergyAccessory` (`src/energy.ts`) is the only place that touches `site.sse`: it re-wraps incoming `live_status`/`site_info` stream events into that same envelope and re-emits them on `site.api`, so every service keeps working unchanged regardless of whether the data came from REST or the stream. `live_status` fully replaces the cached value (it's the primary continuous-update path, no recurring REST poll); `site_info` shallow-merges into the existing cache instead of replacing it, since the stream's `site_info` payload is a slimmer, evolving subset of the full REST response (e.g. tariff content lives in its own `tariff_v2` topic) - don't build anything in this layer that assumes the stream `site_info` event carries the full REST shape. +Builds to a single `dist/index.cjs`. Conventions: node classes end `.node.ts`, credentials `.credentials.ts`, icons referenced from `src/`, and a node's `version` field must match `package.json`. -**Gotcha**: stock hap-nodejs (`CharacteristicDefinitions.d.ts`) has no generic characteristic for arbitrary distance or energy values - only vendor-specific Eve custom characteristics (not in this dependency) cover those. Signals like `MilesSinceReset`/`SelfDrivingMilesSinceReset` (miles) or `LifetimeEnergyGainedRegen` (kWh) have no non-misleading HomeKit mapping and should stay unwired here rather than forced onto an unrelated sensor type (e.g. `TemperatureSensor`); other products (HA, Node-RED) can still expose them as generic sensors. +Same "check before adding" rule as Node-RED: the Trigger node's generic Signal event covers any field from `getFields()`, and `vehicleData()`/`getLiveStatus()`/`getSiteInfo()` cover reads - new operations are only needed for genuinely new **commands** (writes). -**Gotcha**: single-model/config-dependent services must gate on vehicle model/config, never register unconditionally. Two patterns cover this: a single-model signal like Cybertruck's `TonneauOpenPercent` gates its whole service in `vehicle.ts` on `useTeslaModel(vehicle.vin) === "Cybertruck"` (`@teslemetry/api`'s VIN-based model discriminator, `Teslemetry.ts`) before constructing it; a config-dependent feature within an otherwise-universal service, like `DoorService`'s frunk/trunk contact sensors, instead gates just that piece on the vehicle metadata's `config.can_actuate_trunks` (`VehicleDetails.metadata`, sourced from `getApiMetadata` - see `packages/api/src/client/types.gen.ts`'s `GetApiMetadataResponses`), since the VIN alone can't distinguish powered from latch-only hardware within a model line. +**Local testing**: `pnpm build && pnpm link --global`, then `pnpm link --global n8n-nodes-teslemetry` from `~/.n8n/nodes`. -**Gotcha**: not every feature can be gated ahead of time (model/config are known synchronously; whether a given vehicle *reports* a signal at all often isn't). `PresenceService` (`vehicle-services/presence.ts`) and `WallConnectorService` (`energy-services/wall-connector.ts`) don't extend `BaseService`/`BaseEnergyService` and create zero HAP services at construction time - each sub-sensor (per presence field, per Wall Connector DIN) is created lazily the first time its signal/live_status entry actually arrives, since a field withheld by scope or simply absent hardware may never fire. Contrast this with `TpmsService`, `GridOutageService`, and `StormWatchActiveService`, which construct their `ContactSensor`s eagerly (virtually every vehicle/site has the underlying hardware) but hold `StatusFault` at `GENERAL_FAULT` until the first real payload lands, clearing it only then - never default a not-yet-received reading to "safe" by leaving a fresh `ContactSensorState` at its HAP default. Both new and existing contact-sensor mappings in this package (`DoorService`, the TPMS/grid/storm/wall-connector services above) follow one polarity convention: `CONTACT_DETECTED` = normal/quiescent (door closed, no fault, cable seated, grid up), `CONTACT_NOT_DETECTED` = triggered/abnormal (door open, fault active, cable unseated, grid down) - keep new contact sensors on this convention rather than picking polarity per-service. +## `homebridge-teslemetry` -**Gotcha**: `BaseService`/`BaseEnergyService.setStreamFault()` (called from `VehicleAccessory`/`EnergyAccessory`/`TeslemetryPlatform` to reflect terminal account-stream health) only marks a service's `StatusFault` when that HomeKit service type actually declares it as an optional characteristic (checked via `service.optionalCharacteristics`, not `service.testCharacteristic()` - the latter only reports characteristics already added, which excludes `StatusFault` on a service like `DoorService` that never touches it during normal operation). Only sensor-type services (`ContactSensor`, `OccupancySensor`, and similar) declare `StatusFault` at all; core control services (`LockMechanism`, `Switch`, `Thermostat`, `Battery`) don't, and forcing it onto them via `getCharacteristic()` would silently add an out-of-spec characteristic with a HAP warning - `setStreamFault()` is a no-op for those rather than inventing a misleading fault signal. Services owning more than one HAP `Service` instance (`TpmsService`, `DoorService`) override `setStreamFault()` to loop over all of them, not just the primary `this.service`. `PresenceService`/`WallConnectorService` don't extend `BaseService`/`BaseEnergyService` (see above) but hand-implement the same `setStreamFault()` contract over their own lazily-created sensor maps, clearing a sensor's fault the moment its own signal/DIN reading arrives - independent of any debounce applied to the reading's *value*. +`src/vehicle-services/*` and `src/energy-services/*` are thin adapters between a HomeKit `Service`/`Characteristic` and an `@teslemetry/api` method. Publishing requires npm trusted publishing to be registered for this package name (an npm org owner action). -**Gotcha**: `WallConnectorService`'s `connectors` map is otherwise only populated by `live_status`, so per-DIN sensors that already exist in Homebridge's persisted accessory cache from a prior run are invisible to `setStreamFault()` until this run's first `live_status` - its constructor hydrates `connectors` from any matching cached `ContactSensor` services up front (parsed off their `wall-connector-{fault,connected}-` subtype) and immediately applies `TeslemetryPlatform.streamFault` (a public getter over the platform's private terminal-fault flag), so a fault already raised before this accessory was constructed still reaches them. Any other lazily-hydrated per-entity service map should follow the same hydrate-from-cache-at-construction pattern rather than assuming the live stream is the only source of a map's keys. +**Typing**: HomeKit's `Service`/`Characteristic` statics (e.g. `Service.Lightbulb`) are concrete subclasses with simpler constructors than the base classes hap-nodejs types against - generic helpers need `WithUUID<{ new (...): T }>`-shaped types (see `*-services/base.ts`), not `typeof Service`/`typeof Characteristic`, or `addService`/`getCharacteristic` overload resolution breaks. -## Technology Stack +**subType discipline**: `BaseService`/`BaseEnergyService`'s getOrCreate falls back to a type-only `getService`/`addService` lookup only when the caller passes no `subType`. Siblings sharing a service type must always pass a stable unique `subType` or they collapse onto one HAP service - `LockService`/`ChargePortService` (LockMechanism); `ChargeSwitchService`/`DefrostService`/`SentryService`/`WakeService` and `StormWatchService`/`GridChargingService` (Switch). Conversely, services with no sibling sharing their type (Information, Battery, Climate) must keep omitting `subType`: HAP pre-creates a default `AccessoryInformation` service and rejects a second service of the same UUID with a falsy subtype, so forcing one there breaks default-service reuse. `test/serviceCollision.test.ts` and `test/energyServiceCollision.test.ts` cover this. -- **pnpm** (workspaces) - package management; version pinned via `packageManager` in root `package.json` -- **tsdown** - primary build tool (bundles via rolldown); its `rolldown-plugin-dts` dependency must support the installed `typescript` major version for declaration emit to work, since that's the actual compiler-API consumer, not tsdown itself -- **TypeScript** 7.x - type checking and compilation. TS7's config surface dropped `baseUrl` and the `node`/`node10` `moduleResolution` value, and defaults `types` to `[]` instead of auto-including all `@types/*` packages - any tsconfig relying on the old implicit behavior needs `"types": ["node"]` added explicitly -- **Oxlint** - code linting (native TS parsing, no per-package tsconfig project setup needed; config at root `.oxlintrc.json`) -- **tsx** - TypeScript execution (scripts, and each package's `test` script: `tsx --test test/*.test.ts`, Node's built-in test runner) -- **Changesets** (`@changesets/cli`) - version bumping, changelog generation, and publishing -- **Node-RED**, **n8n**, **Homebridge** (hap-nodejs) - platform SDKs for the respective integration package; see each package's `package.json` for the exact supported version +**Gating**: model- and config-dependent services must never register unconditionally. A single-model signal gates the whole service in `vehicle.ts` on `useTeslaModel(vehicle.vin)` (the SDK's VIN-based model discriminator). A config-dependent feature inside an otherwise-universal service gates just that piece on vehicle metadata - e.g. `DoorService`'s frunk/trunk contact sensors on `config.can_actuate_trunks`, since a VIN alone can't distinguish powered from latch-only hardware in a model line. -## Development Workflow +**What can't be gated ahead of time**: whether a vehicle *reports* a given signal often isn't knowable synchronously. `PresenceService` and `WallConnectorService` don't extend the base classes and create zero HAP services at construction - each sub-sensor (per presence field, per Wall Connector DIN) is created lazily when its signal/entry first arrives, since a field withheld by scope or absent hardware may never fire. By contrast `TpmsService`, `GridOutageService`, and `StormWatchActiveService` construct their `ContactSensor`s eagerly but hold `StatusFault` at `GENERAL_FAULT` until the first real payload clears it - **never default a not-yet-received reading to "safe"** by leaving a fresh `ContactSensorState` at its HAP default. -```bash -pnpm install # install all workspace dependencies -pnpm build # build all packages -pnpm --filter build # build one package -pnpm --filter test # run one package's tests -pnpm --filter @teslemetry/api client # regenerate the OpenAPI client -pnpm lint # oxlint across the whole monorepo (single root invocation) -pnpm lint:fix # auto-fix lint issues -pnpm -r --no-bail tsc # typecheck every package, don't stop at first failure -``` - -Config: `.oxlintrc.json` at repo root. Two `overrides` blocks intentionally silence rules that conflict with deliberate patterns rather than bugs: -- `typescript/no-unsafe-declaration-merging` off under `packages/api/src/**` - the `class X extends EventEmitter` + `declare interface X` typed-emitter pattern used throughout the SDK -- `typescript/no-this-alias` off under `packages/node-red-contrib-teslemetry/src/nodes/**` - Node-RED's standard `const node = this;` idiom for capturing node identity inside async callbacks - -The generated OpenAPI client (`packages/api/src/client/**`) is excluded via `ignorePatterns` - don't hand-edit it or add lint overrides for it. - -To make a change: branch, edit the relevant package(s), `pnpm build`, add a changeset (`pnpm changeset`), commit (including `.changeset/`), and open a PR. - -## Release Process +**Contact-sensor polarity** (one convention for the whole package): `CONTACT_DETECTED` = normal/quiescent (door closed, no fault, cable seated, grid up); `CONTACT_NOT_DETECTED` = triggered/abnormal. -Changesets drives version management and automated publishing - see `RELEASE.md` for the detailed process. Summary: `pnpm changeset` records an entry describing which packages changed and the semver bump; merging a PR containing changeset entries to `main` causes a "Version Packages" PR to be created automatically, and merging *that* PR bumps versions, generates changelogs, and publishes to npm (with `--access public`). +**Stream fault**: `setStreamFault()` marks `StatusFault` only when the HomeKit service type declares it in `service.optionalCharacteristics` - checked there, not via `testCharacteristic()`, which only reports characteristics already added. Only sensor-type services declare it; core control services (LockMechanism, Switch, Thermostat, Battery) don't, and forcing it via `getCharacteristic()` would silently add an out-of-spec characteristic, so it is a deliberate no-op for those. Services owning more than one HAP `Service` (`TpmsService`, `DoorService`) override it to loop over all of them. `PresenceService`/`WallConnectorService` hand-implement the same contract over their lazily-created sensor maps, clearing a sensor's fault the moment its own reading arrives, independent of any debounce on the value. -## Key Architecture Decisions +`WallConnectorService`'s `connectors` map is otherwise only populated by `live_status`, so per-DIN sensors restored from Homebridge's persisted accessory cache would be invisible to `setStreamFault()` until this run's first `live_status`. Its constructor therefore hydrates the map from matching cached `ContactSensor` services (parsed off their `wall-connector-{fault,connected}-` subtype) and applies `TeslemetryPlatform.streamFault` immediately. Any other lazily-hydrated per-entity map should follow the same hydrate-from-cache-at-construction pattern. -- **Shared dependency model**: every integration package depends on `@teslemetry/api` via `workspace:*` for a single source of truth and easier maintenance. -- **Dual module format**: the core API package ships both ESM (`dist/index.mjs`) and CommonJS (`dist/index.cjs`). -- **TypeScript**: root `tsconfig.json` sets strict mode, `target: ES2022`, `module: NodeNext`; package configs extend it with their own `outDir`/`include`/platform types. -- **Code generation**: OpenAPI specs auto-generate the client in `packages/api/src/client/` to keep the SDK in sync with the API. +**Signal choice**: `climate.ts` maps `CurrentHeatingCoolingState` off `HvacPower` (the system power enum), not `HvacACEnabled` ("is the AC compressor running") - heating can be active with the compressor off; `HvacACEnabled` only picks HEAT vs COOL once `HvacPower` says the system is on. When wiring a new signal-driven characteristic, check `packages/api/src/client/types.gen.ts` for whether a narrow boolean flag or a proper state enum is the right source of truth. -## Important Files and Directories +**No mapping is better than a wrong one**: stock hap-nodejs has no generic characteristic for arbitrary distance or energy values (only vendor-specific Eve customs, not a dependency here). Signals like `MilesSinceReset` or `LifetimeEnergyGainedRegen` stay unwired rather than forced onto an unrelated sensor type; other products can expose them as generic sensors. -- `pnpm-workspace.yaml`, root `tsconfig.json`, root `package.json` - workspace/build config -- `RELEASE.md` - release process documentation -- `.changeset/` - changeset entries for version management -- `openapi-ts.config.ts` (api package) - OpenAPI code generation config +**Energy data path**: `energy-services/*` never touch `site.sse` - they subscribe to `site.api.on("siteInfo"|"liveStatus", ...)` and destructure the REST envelope (`{ response: {...} }`). `EnergyAccessory` (`src/energy.ts`) is the only place touching `site.sse`: it re-wraps stream events into that envelope and re-emits them on `site.api`, so services work identically for REST and stream data. `live_status` fully replaces the cached value (it's the primary continuous-update path, no recurring poll); `site_info` shallow-**merges**, because the stream's `site_info` is a slimmer evolving subset of the REST response (tariff content arrives on its own topic) - never build anything here assuming the stream `site_info` carries the full REST shape. -**Gotcha**: energy-site SSE event union members are inconsistent about which field identifies the site - `live_status`/`site_info` carry `site_id`, but `energy_totals` (and any future refresh-notification event sharing that uniform `{id, product_type, topic, url, createdAt, isCache}` shape) carries `id` instead. `TeslemetryStream._dispatch()`'s two routing blocks (one per field name) reflect this; adding a new energy SSE event means checking which field the backend's schema actually uses, not assuming `site_id`. Every `on()` override across `TeslemetryStream`/`TeslemetryVehicleStream`/`TeslemetryEnergySiteStream` must call `super.on()` before replaying any cached value to the listener - reversing that order silently breaks `once()` (see `test/energyStream.test.ts`'s dead-listener regression tests). - -**Gotcha**: `src/sseTopics.ts`'s `SSE_TOPICS` is the client-side mirror of the API's `src/lib/sseTopics.ts` allowlist - keep both in sync when the backend adds a wire event, or the new topic can never be selected via `stream.topics`. `SSE_TOPIC_PRESETS` entries are SDK-only convenience and are always expanded to exact wire names client-side before the `topics` query parameter is built - never send a preset name or a wildcard over the wire. `tariff_content_v2`'s body is `null` for the server's explicit tariff-removal signal, not merely "no update yet" - `EnergySiteCache.tariff_content_v2` distinguishes that (`null`) from "never received" (`undefined`), and replay/cache logic must preserve the distinction rather than treating `null` as falsy-skip. +**Testing**: `test/fakePlatform.ts`, `test/fakeVehicle.ts`, `test/fakeEnergySite.ts` fake the Homebridge/HAP/SDK objects. Real hap-nodejs `Service`/`Characteristic` classes are used (not mocks) so get/set wiring behaves as at runtime - drive them via `handleSetRequest`/`handleGetRequest`, not `setValue`, which fires the handler asynchronously with nothing to await. `vehicle.api`/`site.api` are plain stubs to keep tests free of network I/O. ## CI/CD -`.github/workflows/reusable-ci.yml` holds the full lint/build/typecheck/test/codegen-verify suite as a `workflow_call` reusable workflow, called by both `ci.yml` (PR/push triggers) and `publish.yml`'s `validate` job, so there is exactly one place to add or change a check. This repo has no branch protection, so `publish.yml`'s `release` job (which enters the `production` environment and runs `changeset publish`) depends on `needs: validate` in the *same workflow run* - that guarantees the full CI suite ran against the exact SHA being published, not a separate/racing CI run on the same push. Don't restore CI as inline steps in `publish.yml` or drop the `needs: validate` gate. - -`publish.yml`'s "Upgrade npm for OIDC support" step always installs `npm@latest`, whose `engines.node` requirement can rise ahead of the workflow's `actions/setup-node` pin. If publish starts failing with `EBADENGINE`, check `npm view npm@latest engines` against the pinned `node-version` first. - -`pnpm/action-setup` in `publish.yml`/`ci.yml` must use `@v4` with no hardcoded `version:` (it then reads the `packageManager` field in root `package.json`). A hardcoded-major pin that drifts from `packageManager` makes `changeset publish` silently fall through to a plain `npm publish` that rejects the `--git-checks` flag changesets passes for the pnpm path, failing with `EUNKNOWNCONFIG` - this looks like a changesets/flag bug but is actually a pnpm-version mismatch. Keep root `packageManager` and both workflow files' pnpm major in sync. - -## Resources +`.github/workflows/reusable-ci.yml` holds the whole lint/build/typecheck/test/codegen-verify suite as a `workflow_call` workflow, called by both `ci.yml` and `publish.yml`'s `validate` job - exactly one place to add or change a check. -- **Teslemetry API Docs**: https://teslemetry.com/docs -- **pnpm Workspaces**: https://pnpm.io/workspaces -- **Changesets**: https://github.com/changesets/changesets -- **Node-RED**: https://nodered.org/docs/creating-nodes/ -- **n8n**: https://docs.n8n.io/integrations/creating-nodes/ -- **Homebridge Plugin Dev**: https://developers.homebridge.io/ +- This repo has no branch protection, so `publish.yml`'s `release` job (which enters the `production` environment and runs `changeset publish`) depends on `needs: validate` in the *same* run, guaranteeing CI ran against the exact SHA being published. Don't inline CI steps back into `publish.yml` or drop that gate. +- `publish.yml`'s "Upgrade npm for OIDC support" step installs `npm@latest`, whose `engines.node` can rise above the workflow's `setup-node` pin. On an `EBADENGINE` publish failure, check `npm view npm@latest engines` against `node-version` first. +- `pnpm/action-setup` must stay `@v4` with **no** hardcoded `version:`, so it reads root `package.json`'s `packageManager`. A pin that drifts makes `changeset publish` fall through to plain `npm publish`, which rejects the `--git-checks` flag changesets passes on the pnpm path and fails with `EUNKNOWNCONFIG` - it looks like a changesets bug but is a pnpm-version mismatch. ## Maintaining this file