Skip to content

PELS Architecture Contract ​

PELS is layered. Modules in a higher layer may depend on modules in lower layers, never the other way round. dependency-cruiser enforces the import boundaries on every CI run (npm run arch:check), with .dependency-cruiser.cjs as the source of truth for those checks. Ownership rules also require review: an allowed import does not establish that code belongs in a package.

This page is the public contributor reference. Use it when you are deciding where new code goes, or why a refactor is being asked to move modules around.

Layer overview ​

┌─────────────────────────────────────────────────────────────┐
│ Entry points                                                │
│   app.ts · api.ts · drivers/** · widgets/*/src/**           │
│   packages/settings-ui/src/script.ts                        │
├─────────────────────────────────────────────────────────────┤
│ App wiring — stateless                                      │
│   setup/** · flowCards/**                                   │
├─────────────────────────────────────────────────────────────┤
│ Planner-input integration                                   │
│   lib/planInput/**                                          │
├─────────────────────────────────────────────────────────────┤
│ Domain modules                                              │
│   lib/device · lib/power · lib/objectives · lib/plan        │
│   lib/executor · lib/actuator · lib/observer · lib/price    │
│   lib/dailyBudget · lib/battery · lib/thermostat            │
│   lib/solar · lib/weather · lib/home · lib/diagnostics      │
│   lib/flowApi                                               │
├─────────────────────────────────────────────────────────────┤
│ Shared utilities and contracts                              │
│   lib/utils · lib/logging · lib/store · lib/ports           │
│   lib/planContract · packages/contracts/src/**              │
│   packages/shared-domain/src/** · packages/planner-types/src/**
├─────────────────────────────────────────────────────────────┤
│ Test code (not imported by anything runtime)                │
│   test/** · packages/settings-ui/test/** · tests/**         │
└─────────────────────────────────────────────────────────────┘

What each layer is for ​

LayerPurposeExamples
Entry pointsBoot the runtime or render the settings UI. Wire dependencies but contain no domain logic.app.ts (Homey app entry), api.ts (settings UI and widget routes), drivers/pels_insights/ (virtual device), widgets/*/src/ (dashboard widgets), script.ts (settings UI bootstrap)
App wiringHand the domain modules the Homey SDK handles and Flow cards they work through, holding nothing and deciding nothing. This is where dependency injection happens. Wiring lives in setup/ and flowCards/.setup/settingsRepository.ts, setup/backgroundTasksController.ts, flowCards/registerFlowCards.ts
Planner-input integrationProject owner-resolved inputs into lib/plan/. The one-way seam: no domain module imports it.lib/planInput/
DomainPure planning, capacity, price, budget, and observation logic. No Homey SDK imports or types — a domain module reads through a structural port (lib/ports/homeyRuntime.ts) handed to it by setup/, so the call reaches the SDK at runtime without the module depending on it. lib/device's transport is the one declared SDK leaf. No UI imports.lib/plan/planEngine.ts, lib/device/deviceTransport.ts, lib/power/tracker.ts, lib/objectives/profiles.ts, lib/observer/idleClassifier.ts
Shared utilities and contractsBackend utilities live under lib/. lib/ports/ holds the structural Homey ports, and lib/planContract/ the neutral contract shared by planner, executor and diagnostics. packages/shared-domain/** holds logic that must run in both the browser and Node, and must remain browser-safe and Homey-SDK-free. packages/planner-types/** holds the planner I/O contracts below the domain peers. lib/store/** owns the app's SQLite file under /userdata and is runtime-only.lib/utils/*, packages/shared-domain/src/smartTaskCopy/
Test codeSpecs and mocks. Runtime cannot import it.test/, packages/settings-ui/test/

Hard rules (CI-enforced) ​

The following rules are encoded in .dependency-cruiser.cjs:

  1. No circular dependencies anywhere in the runtime or shared packages.
  2. Runtime code must not import test code. "Runtime code" here means everything under app.ts, lib/**, setup/**, flowCards/**, drivers/**, and packages/{settings-ui,contracts,shared-domain,planner-types}/src/**.
  3. Backend must not import the settings UI. Backend is app.ts, api.ts, lib/**, setup/**, flowCards/**, drivers/**. The boundary is one-way.
  4. Settings UI must not import the backend. The settings UI may only consume packages/contracts/** and packages/shared-domain/**. The same shared-domain helpers are used by both sides, so user-visible strings and runtime log strings stay in lockstep.
  5. Shared packages must not import the runtime. packages/contracts/**, packages/shared-domain/** and packages/planner-types/** cannot reach into app.ts, api.ts, lib/**, flowCards/**, or drivers/**. This is what keeps the settings-UI bundle browser-safe.
  6. Domain modules must not import lib/app/** or lib/planInput/**. Domain logic is independent of wiring, and the planner-input seam is one-way (rules no-domain-to-app-layer, no-domain-to-plan-input-integration).
  7. lib/** and packages/** must not import setup/** (rule no-lib-to-setup). The arrow always points from setup/ down into the libraries it wires; see the App wiring lives in setup/ section below.
  8. flowCards/** and drivers/** must not import packages/settings-ui/**.
  9. Non-entry modules must not import app.ts.

If any of these break, CI fails before tests run. Local check: npm run arch:check.

App wiring lives in setup/ ​

setup/ at the repo root is the honest home for app-wiring classes — factories, observers, registrars that construct and connect services. These have no reuse value outside this app, so they live at the entry layer rather than masquerading as library code under lib/.

It constructs and connects; it does not run, and it does not remember. No mutable field, no module-level let or var, no field holding a mutable container — readonly pins a reference, not its contents. Anything that changes as the app runs is a component owned by the lib/ module that owns the concept, and setup builds it and hands it its collaborators. The composition root is app.ts, which holds the constructed services as readonly fields.

State in the wiring layer is state with no owner: it sits above the boundaries the dependency graph enforces, so anything wired can reach it, and it becomes a channel between modules the rules forbid to talk — with no import edge for arch:check to see. The same is true of logic, and of SDK contact: setup/ hands the module a SettingsPort/FlowPort/ApiPort from lib/ports/homeyRuntime.ts and the module does its own reading, classification and migration, with the port's cache, dirty flag and grace window living beside it in lib/. The setup/*Adapter.ts files predate this and are migrating out. A let that a wiring function declares and its callbacks keep reassigning is the same state held in a closure. npm run setup:stateless enforces this. Its allowlist budgets the closure variables that predate the closure check, and those budgets may only shrink.

It decides nothing, and it does not touch the SDK. No domain logic, no data structures, no parsing, normalizing, validating, classifying, projecting or arithmetic over domain values. A file needing two domain peers at once is a concept nobody has named, not cross-cutting code needing a home — name it and give it a module. setup/AGENTS.md § "No domain logic" gives the working rule; the ownership table below gives its destinations.

Direction is enforced. The no-lib-to-setup rule blocks any import from lib/** or packages/** into setup/**. Wiring imports the libraries it wires; never the reverse.

Conventions (reviewed at PR time, not cruiser-enforced):

  • One purpose per file, named for the concrete wiring it does (settingsRepository.ts, backgroundTasksController.ts). No grab-bag setupHelpers.ts.
  • Each file exposes a class, or a single register* / init* / create* function. Not bags of utility functions. Two carve-outs serve the app API. Each setup/api/<family>Routes.ts exports one route table built with defineRoutes, which wraps every handler in the shared API logging under its route name; api.ts only spreads those tables, and npm run api:manifest-parity checks their union against the api keys in .homeycompose/app.json. The settings-UI/widget endpoint handler files (settingsUiApi.ts, settingsUiHomesApi.ts, settingsUiStarvationRescueApi.ts, settingsUiSmartTaskApi.ts) each export the handler set a route table forwards to. Cohesion still binds: a route joins the table of its endpoint family, and a new endpoint family gets a new table, never an extra entry bolted onto an unrelated one.

lib/app/ has dissolved as a wiring layer. The directory holds lib/app/appContext.ts (the shared AppContext type), lib/app/pelsHost.ts (the typed PelsHost app host the widgets and API read) and lib/app/setupNotification.ts (the one-time setup reminder). Wiring goes in setup/; nothing new belongs in lib/app/.

Where new code goes ​

You are adding…It belongs in…
A new Flow cardA topical file under flowCards/ (the directory is flat by purpose, not by trigger/condition/action), with the card JSON under .homeycompose/flow/<triggers|conditions|actions>/
A new planner rulelib/plan/ — but the rule must be pure and unit-testable without a Homey instance
New UI on the settings pagepackages/settings-ui/src/ui/ — read state from contracts; emit changes through the API surface
Logic that must execute in both the browser and Nodepackages/shared-domain/src/ — identify the real consumers in both environments
A user-facing string also written to logspackages/shared-domain/src/ — both the UI and the runtime logger must import it from there
A type used on both sidespackages/contracts/src/
App-wiring code (factory, observer, registrar that constructs/connects services)setup/ — one purpose per file, exposes a class or single register*/init* function, and holds neither state nor domain logic. See App wiring lives in setup/.
Logic that needs two or more domain peers at onceNot setup/. Name the concept: a domain service goes in the lib/ module owning it, beside its port; a projection between two peers' shapes goes in a neutral contract module like lib/planContract/. See setup/AGENTS.md § "No domain logic".
Something that must be remembered between calls (a cache, latch, counter, ledger, in-flight marker)The lib/ module that owns the concept, as a component setup/ constructs. Never a field in setup/ — see App wiring lives in setup/.
A Homey-SDK adapterThe lib/ module that owns the concept, taking a narrow structural port from lib/ports/homeyRuntime.ts. setup/ wires the port in; it does not read the SDK itself. The port's remembered state and the classification of what it reads both live with the port. The existing setup/*Adapter.ts files predate this and are migrating out — see setup/AGENTS.md § "Adapter naming".

Shared-domain ownership ​

packages/shared-domain/ is for code that must run in both the browser and the Node runtime. Being browser-safe is necessary but does not establish that need. Neither reuse by several backend modules nor a possible future UI consumer justifies moving code there. Backend-only logic belongs in its owning lib/ module; browser-only logic belongs with its browser consumer.

For example, consumption attribution shared by power and planner belongs in lib/power/, which the planner may import. Moving it into shared-domain merely to avoid a forbidden peer import would obscure its owner.

Reviewers must identify the browser and Node consumers, including indirect use through shared entry points. Import-boundary and browser-compatibility checks cannot establish this ownership requirement; passing them is not evidence of appropriate placement.

When duplication is the right call ​

If consolidating two helpers would require crossing a boundary (e.g. a runtime module reaching into the settings UI, or a domain module pulling something from setup/), leave the duplication in place and add a one-line comment explaining the constraint. The architecture cost of a back-door is higher than three lines of repeated arithmetic.

Clean and trusted interfaces between layers ​

Every layer boundary follows one rule with two faces:

  • Clean is the emitting side's obligation. Whoever hands a value across a boundary — an adapter resolving Homey SDK reads, the planner emitting a plan, the read model building a UI snapshot — resolves and validates it first. The interface says exactly what it means and nothing more: no raw NaN or malformed input flowing inward, no field declared optional that the emitter in fact always writes, no two spellings of absence for one quantity, no display payload riding on an actuation contract.
  • Trusted is the consuming side's obligation, and it is only possible because of the first: read the value directly. No re-validating what the type already guarantees, no re-deriving what the emitter already resolved, no branching on the source, evidence, or provenance of the value received.

Trust is scoped to in-process handoffs of already-typed values. A boundary that crosses an untrusted transport — a network fetch, the Homey API bridge into the settings WebView, a persisted blob that may predate the current schema — is an external edge again: the receiving side's own adapter validates and discriminates the payload once (that is its clean-face duty toward its own consumers), and everything inward of that seam trusts. The settings UI does exactly this: it discriminates plan snapshots at its parse seam (planSnapshotParse.ts) and validates API envelopes at the fetch adapter, never inside the formatters.

Device capability validation and device admission are separate decisions. At the device transport boundary, a null, malformed, missing, or undated capability rejects only that field, including fields a vendor adapter converts. Other valid fields still update. A rejected binary control field such as onoff removes the binary facet even when an older accepted value exists; its declaration, descriptor, or old value must not force binary admission. Other observations keep their last accepted value and original timestamp according to their owner's policy, or are withheld if never accepted. Genuine native-control metadata and usable ranges remain independent of rejected readings; they do not supply an observed value or binary authority. The device owner applies its admission gates to the remaining semantic facets, preserving healthy features and omitting the device if all gates fail. No blank unsupported row is required. A device payload without a usable identity or capability list is dropped at the SDK parse (parseHomeyDevicePayload), which also normalizes Homey's older driverUri and local driverId shape. See lib/device/AGENTS.md and lib/device/transport/deviceReadContract.ts for the device-owned contract.

Concretely: the planner emits the binding softLimitKw, so no consumer combines the capacity, daily-budget and grid-import limits itself. If a consumer needs to explain why the value is what it is, the emitter also emits a separate softLimitSource field.

The two faces enforce each other, which yields a diagnostic: a consumer that hedges — re-checks finiteness, sniffs for a key's presence, keeps a fallback derivation — is evidence of an unclean interface upstream. Fix the interface it stopped trusting, not the hedge.

The rule applies at every seam, not just the SDK edge: observer → planner, planner → executor, planner → read model, read model → UI, and internal handoffs such as PlanContext → plan meta. It isn't checked by the cruiser, but breaking it is the most common reason a feature ends up tangled across layers.

Existing comments cite the two faces under their former names, and both names refer to this section: "Validation belongs at the boundary" is the clean face at the external-input edge (root AGENTS.md keeps the operational checklist), and "Resolution belongs in the producer" — the resolution-in-producer rule — is the emitter-resolves-so-the-consumer-can-trust pairing.

Device control resolution ​

lib/device chooses the usable control ladder when it accepts a device read. Enabled native control owns its native ladder; otherwise configured target-power control supplies its confirmed ladder, followed by a validated saved Flow ladder. Native suggestions belong to the picker and never determine runtime control. DeviceConfigurationRead has a required profile in its stepped branch.

lib/planInput composes that configuration with Observer's accepted report and executor command state. It has no saved-profile lookup or native-hint resolver. Planner input, command confirmation, lifecycle release and UI runtime projections use the same chosen profile. EV planning may derive a probe candidate from the confirmed profile without replacing the confirmed device configuration. Flow feedback is admitted by the device owner before the executor records it; confirmation uses the admitted step observation's own timestamp. Settings changes reparse configuration and retire command sessions referencing removed rungs; the next decision admits any subsequent write.

Device status for UI and logs ​

PlanExecutor.getDeviceExecutionStates(plan) is a read-only query over the committed executable intent, current accepted Observer records, and command stores. It distinguishes physical on/off from the binary handle, reported steps from planning fallbacks, and pending work from settled axes. A binary-off intent does not require a retained active step to change. This query never settles stores, rebuilds a plan, or issues a write.

The settings overview read model combines these executor conclusions with the committed decision's reason and observed display measurements once. It emits a required DeviceStatus: card type, state label/tone, power/fact/reason text, countdown deadline, resolved rail labels/position, and aggregate display flags. The same snapshot feeds realtime UI publication and activity/runtime logs. The UI wire contract explicitly excludes binary state, step evidence/targets, pending commands, planner reasons, and temperature targets. UI consumers render status; they do not reconstruct it from runtime inputs. The receiving JSON boundary rejects malformed or missing status and strips retired state fields.

Device observations refresh this presentation through the owning home's PlanService even when no command is pending. This preserves the decision and its generation timestamp and does not rebuild or reapply it. A countdown may decay in the browser, but expiry cannot invent a state transition. No second mutable device-state store is introduced.

Peer DAG inside the domain layer ​

The domain peers (lib/device, lib/power, lib/objectives, lib/observer, lib/plan, lib/price, lib/dailyBudget, lib/executor, lib/actuator, lib/battery, lib/thermostat, lib/solar, lib/weather, lib/home) are not flat. The imports between them form the directional graph below; the edges forbidden by the named cruiser rules fail the build.

executor → {observer, power, planContract, diagnostics}
executor ↛ plan       (executor:plan-edge, value and type)
executor → actuator   (type-only: the injected Actuator)
plan → {power, dailyBudget, price, observer, diagnostics, planContract}
     ↘ device  (only deviceActionProjection.ts and deviceResidualKw.ts)
plan ↛ {objectives, executor}  (arch:grep, value and type)
plan ↛ {battery, thermostat, solar}; plan, executor ↛ actuator values (no-actuator-bypass)
dailyBudget → {power, price}
thermostat → price
device → power      (type-only: sole-meter adoption)
objectives → {power, home}   (home is a pure leaf; smart-task Main-home scope reads the membership port)
diagnostics → {plan, power, home}
planInput → {plan, observer, device, executor, objectives, price, home, battery}
leaves: power, price, observer, actuator, battery, weather, solar, home

The rules behind this DAG (no-power-to-peer-except-objectives, no-device-to-peer-except-power, no-plan-to-device, no-observer-to-peer, no-price-to-peer, no-thermostat-to-peer-except-price, no-actuator-to-peer, no-battery-to-peer, …) were the gate that drove the lib/app dissolution to completion: a helper that, if pushed into a peer, would create a forbidden edge cannot stay inside one.

That gate is not a licence to park logic at the composition root. This paragraph used to end "wiring residue stays at the composition root (app.ts or setup/**), not inside a peer", and that sentence is withdrawn. It is how setup/ reached 138 files (at 41e19c454) with 58% of its lines importing two or more peers and the same logic density as lib/plan — a wiring layer by name only. Code that needs two peers is not residue; it is a concept nobody has named. Name it and give it a module: a domain service goes beside the port it implements, and a producer projects its resolved result into a neutral contract (lib/planContract/ is the built precedent). setup/** gets the wiring and nothing else — see setup/AGENTS.md § "No domain logic".

Planner and execution boundary ​

lib/plan/planExecutionProjection.ts projects the finalized decision into the neutral ExecutionPlan. It selects device commands and qualifies stable work, and carries storage and inverter decisions. Its named step baseline records historical state at decision time; it never becomes a live observation. Setup forwards this projection without selecting actions.

Execution materializes commands using its current SteppedCommandStore sessions and reads accepted observations separately. ExecutionState owns actuation history and pending command backing; the observer-owned binary store remains the sole writer of binary pending entries. Planning receives live readonly execution views through PlanExecutionRead. PlanExecutionFeedback applies execution results to planner-owned admission, shed membership and shortfall state through a narrow behavior port.

Neither owner imports the other. no-executor-to-plan rejects compiled imports; npm run executor:plan-edge also rejects erased type imports, re-exports and dynamic imports. It restricts local planContract imports to neutral contract/shared packages, planContract, logging and utilities, and rejects Homey SDK imports. This checks direct imports, including entry points and setup; it does not traverse allowed utilities.

Realtime event flow ​

Realtime device events (capability updates, full device updates from Homey) cross two peer layers between SDK ingress and the observed state the planner reads:

  1. Translation — lib/device/ (DeviceTransport + lib/device/transport/managerRealtimeHandlers.ts) parses the raw Homey payload, runs the admit-or-suppress flow-vs-binary rule and pending-binary-command echo suppression, and produces normalized plan_live_state_observed / observed_control_state_changed events (OBSERVED_STATE_CHANGED_EVENT / OBSERVED_CONTROL_STATE_CHANGED_EVENT).
  2. Observer fan-out — lib/observer/observedStateEvents.ts owns the typed-event emitter (ObservedStateEmitter). Transport routes each event through a dispatcher callback bag (observedStateDispatcher) injected at construction time by wiring, so lib/device/ → lib/observer/ stays free of static imports (the no-device-to-peer-except-power cruiser rule holds).
  3. Wiring — setup/appInit/planObservedStateSubscription.ts subscribes to the observer-owned emitter and updates the observed view, the external-off hold, the live status, and the rebuild-suppression latches (all routed to the device's owning home, setup/appObservedControlStateRuntime.ts). It requests no plan rebuild. Two things had to go here in turn: first the wiring-layer drift gate, which asked a planner question setup/ had no business answering and used the answer only to decide whether to RE-APPLY the committed plan — the lane that breached the hard cap in production (inc_26449fb9); then the device-event rebuild trigger itself, because a capacity decision taken on a device event runs against a whole-home reading taken before the change. The trigger is a meter reading (lib/plan/planRebuildTrigger.ts), and the observation reaches the planner as state, in the reading that carries it.

The executor's drift verdict (lib/executor/planExecutionDrift.ts, PlannedDeviceCommands vs ExecutableObservedDeviceState) is deliberately not a step in this flow: it runs inside a rebuild's apply phase, not on the realtime path. Observer and transport never see plan intent.

See notes/state-management/observer-transport-split.md for the layering rationale and the six-step split-train history.

Boundary enforcement ​

The named .dependency-cruiser.cjs rules and companion source guards enforce these owner boundaries:

  • lib/plan/** imports no executor modules. Setup owns the concrete planner/executor composition and implements the narrow PlanEngine behavior contract; neutral cross-boundary result types live in lib/planContract/. The cruiser rejects compiled plan→executor edges through no-plan-to-executor, and the source AST guard behind npm run arch:grep rejects type-only and dynamic forms before compilation erases them.
  • lib/plan/** and lib/executor/** hold no read interface over the device transport: observed state reaches them from the observer projection (lib/observer/observedDeviceStateProjection.ts), and the executor joins it with the transport's descriptors in lib/executor/executorDeviceRead.ts. The no-plan-to-device cruiser rule allows lib/plan/** only the two producer seams (deviceActionProjection.ts, deviceResidualKw.ts), and no-executor-to-device-internals allows the executor nothing. Binary control writes are dispatched by executor (lib/executor/binaryControlDispatch.ts), not plan. The orchestrating class is now DeviceTransport at lib/device/deviceTransport.ts — see notes/state-management/observer-transport-split.md for the layering rationale and the per-PR split history.

If you find a cross-layer import that has no named cruiser rule covering it, treat it as a bug, not a precedent.

Built for Homey Pro users who need tighter control over large loads.