Skip to content

Architecture Deep-Dive

This page is for contributors who want to change the engine rather than just author a profile. For the high-level picture, start with Architecture.

The core of the “inverter is data” design is a small in-memory profile registry (packages/inverter-core/src/registry.ts) — a Map<string, InverterProfile>. Profiles get into it two ways:

  1. Code profiles (first-party npm packages) can self-register via a side-effecting import that calls registerProfile() and may carry a real simulate function and closures. None ship in the core today — profiles are authored with @sunreye/profile-sdk and downloaded.
  2. Data profiles (downloaded) are read from the database, validated, hydrated into the same InverterProfile shape, and registered.

Everything downstream — the driver, manifest, capabilities, transports — consumes InverterProfile and never knows whether a profile originated as code or data.

  • ProfileData (profile-data.ts) is the serializable artifact: the download file and the installed_profiles DB row. Its metrics use a declarative computeExpr instead of code.
  • InverterProfile (types.ts) is the runtime shape used by the engine. hydrateProfile() compiles each computeExpr into a real (values) => number closure, producing a single internal representation.

The role catalog (roles.ts) derives CanonicalRole (type CanonicalRole = keyof typeof ROLE_CATALOG) so the type and the runtime data can never drift. Capability derivation (capabilities.ts) turns the set of present roles into the InverterCapabilities the UI renders from, and buildManifest() produces the render-ready view sent to the browser and the API.

Profile resolution is asynchronous and runs in initProfiles() before any routes, manifest, or MQTT topics are built:

  1. Any code profiles self-register (import side effects) — none in the core today.
  2. loadInstalledProfiles() reads every installed_profiles row, re-validates it (a stored row may predate a schema change), hydrates and registers it; an invalid row is logged and skipped so one bad download can’t take the server down.
  3. The active profile id is resolved from the activeProfile setting (seeded from INVERTER_PROFILE on first boot), and the engine is built for it.

Because the active profile shapes the REST routes, manifest, and MQTT topics once at boot, switching it is restart-scoped by design — see Distributing Profiles.

Entities are transport-neutral: each metric yields a constraint (bounds, enum, writable) via entityConstraint(). Every transport generates from these:

  • The REST /api/v1 sub-app folds over the writable metrics to emit one validated PUT route per writable entity, captured into the chain so they appear in the OpenAPI spec.
  • The MQTT bridge builds state/command/availability topics per metric and derives Home Assistant discovery component types from the same constraints.
  • The dashboard resolves every widget by role from the manifest.

Adding a metric extends all three with no route/topic/UI code.

Telemetry is stored narrow — one row per metric per tick, keyed by inverterId and metric key — in a TimescaleDB hypertable (packages/db). Continuous aggregates provide per-minute / hourly / daily rollups; retention and compression policies keep raw data bounded while preserving long-range trends. A new inverter needs no migration because nothing is vendor-columned.

Runtime settings (inverter connection, MQTT, tariff, profile sources, active profile) live in an app_settings table as JSONB with per-key Zod schemas, hot-reloaded on write (except the active profile, which is restart-scoped).

Separate from the stable /api/v1 surface, the dashboard uses /api/* routes (session auth; mutations admin-only): the manifest (/api/profile), the live WebSocket (/ws/metrics), history and rollup endpoints, cost/energy, and the settings CRUD + test/status endpoints that back the Settings screen. These are internal — integrations should use /api/v1.

See Architecture → Packages for the full list.