Skip to content

About

Device topology and AsyncAPI v3 topic provisioning

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

EMS Device API 🌳

📖 About

The API manages the canonical DTM (Device Topology Manifest) per ADR-002 §7 — a flat dict of devices keyed by snake_case slug, each with an optional parent: device_id pointer. Hierarchy depth is unbounded; modules contain racks, racks contain BMS/inverter/cells, etc. Internal callers (platform-api, commissioning tooling) fetch the full DTM via GET /topology. HMI fetches the sanitized projection GET /topology/view (per system_adr §22). Both walk parent chains client-side over the same devices shape.

The AsyncAPI v3 spec generated by this service is the single contract consumed by ems-industrial-gateway and ems-hmi. Topic shape, payload schemas, unit vocabulary, and versioning are governed by ems/topic_structure_adr.md.

Diagrams

rectangle "compute_module_1\n(parent: null)" as cm
rectangle "grid_module_1\n(parent: null)" as gm
rectangle "bess_module_1\n(parent: null)" as bm
rectangle "gpu_node_1\n(parent: compute_module_1)" as n1
rectangle "cdu_1\n(parent: compute_module_1)" as cdu
rectangle "switchgear_1\n(parent: grid_module_1)" as sw
rectangle "bess_rack_1\n(parent: bess_module_1)" as br
rectangle "bess_cell_1\n(parent: bess_rack_1)" as bc

cm -d-> n1
cm -d-> cdu
gm -d-> sw
bm -d-> br
br -d-> bc

note right of bm
  Parent chains arbitrary depth.
  Internal callers fetch GET /topology;
  HMI fetches GET /topology/view.
  Both walk device.parent client-side.
end note

Topology Creation Flow

participant client
participant device_api
database typeorm_db

client -> device_api: POST /topology
device_api -> typeorm_db: save device sitemap
device_api -> device_api: generate AsyncAPI v3 spec\n(topics + schemas + x-* protocol bindings)
device_api -> typeorm_db: save spec
device_api -> client: topology created

Spec and Topology Consumers

participant device_api
participant industrial_gateway
participant platform_api
participant ems_hmi

device_api -> industrial_gateway: GET /asyncapi\n(channels + x-protocol-source + x-enum-values)
device_api -> platform_api: GET /topology\n(full DTM)
device_api -> ems_hmi: GET /asyncapi\n(channels + schemas)
device_api -> ems_hmi: GET /topology/view\n(sanitized DTM projection per system_adr §22)
device_api -> ems_hmi: GET /topology/sld.svg\n(generated SVG per system_adr §6)

Endpoint scope by consumer:

  • GET /asyncapi — messaging contract. Channels, payload schemas, x-enum-values, poll rates. x-protocol-source extension carries Modbus/SNMP/etc. bindings for the gateway. Consumed by ems-industrial-gateway and ems-hmi (HMI ignores x-protocol-source). Used for TypeScript codegen at build time and topic subscription at runtime.
  • GET /topology — full DTM including gateway-only fields (connection.host, connection.port, connection.unit_id, per-measurement binding). Consumed by platform-api and internal commissioning tooling. Not consumed by ems-hmi — those fields don't belong in customer browsers.
  • GET /topology/view — sanitized projection of the DTM per system_adr §22. Strips connection.* and per-measurement binding; inlines per-template measurement metadata (unit, display_name_default, iec_61850_ref, bounds, thresholds, poll_rate_hz, enum values) that HMI needs. Consumed by ems-hmi.

Build-time vs runtime

The spec serves two purposes at different times:

  • Build-time: Message schemas (e.g. BooleanReading, FloatReading, CommandPayload) are stable across all deployments. The HMI runs @asyncapi/modelina against the spec to generate TypeScript interfaces. These are compiled into the app. The gateway does the same for Rust structs.
  • Runtime: Topic paths (e.g. arcnode/{deployment_uuid}/{module_id}/{device_uuid}) are deployment-specific — they depend on which modules and devices are in the DTM. These cannot be compiled in. Both gateway and HMI fetch GET /asyncapi at startup and resolve topic paths dynamically.

Message types are compiled. Topic paths are fetched.

When the topology changes at runtime (device added, sensor goes offline, DTM re-provisioned), device-api publishes to system/topology_changed with { ts, version } where version is monotonically bumped per ADR-002 §10. Gateway, line-controller, and HMI subscribe to this topic — on receipt they re-fetch and diff against their current subscriptions.

participant device_api
queue broker
participant industrial_gateway
participant line_controller
participant ems_hmi

device_api -> broker: publish system/topology_changed\n{ ts, version }
broker -> industrial_gateway: forward
broker -> line_controller: forward
broker -> ems_hmi: forward
industrial_gateway -> device_api: GET /asyncapi
line_controller -> device_api: GET /asyncapi
ems_hmi -> device_api: GET /asyncapi
ems_hmi -> device_api: GET /topology/view
ems_hmi -> device_api: GET /topology/sld.svg
industrial_gateway -> industrial_gateway: diff + reconcile topic subs
line_controller -> line_controller: diff + reconcile topic subs
ems_hmi -> ems_hmi: diff + reconcile topic subs + swap SVG

Day-1 Boot

Per system_adr §23, the API reads a DTM from a JSON file at the path set in the BOOT_DTM_PATH env var at startup. Same code across cloud, ISO, dev, CI — only how the file lands at that path varies.

Cloud (CFN + EC2 + docker-compose)

Compose service block:

device-api:
  environment:
    BOOT_DTM_PATH: /app/dtm.json
  volumes:
    - /opt/arcnode/dtm.json:/app/dtm.json:ro

EC2 UserData curls https://arcnode-public/orders/<id>/dtm.json to /opt/arcnode/dtm.json before docker compose up.

On-prem ISO appliance

Same compose block. ISO bake step writes the file to /opt/arcnode/dtm.json.

Dev (docker-compose)

Place a fixture at dev-fixtures/dtm.json and use the dev compose override that mounts it. Or omit BOOT_DTM_PATH and POST DTMs explicitly during dev.

Tests / CI

Omit BOOT_DTM_PATH. Service boots empty; tests POST DTMs explicitly via tests/topology.test.ts patterns.

Behavior matrix

BOOT_DTM_PATH Topology table Result
set empty read + seed
set populated read + skip seed (don't overwrite operator changes)
unset empty graceful empty start
unset populated graceful empty start

Any read/parse/validation/catalog error when env var is set → fatal exit.

Local Development Setup

The device_templates/ directory is bundled into the production image at build time from the sibling edp-api repo. For local dev, symlink:

ln -s ../edp-api/device_templates device_templates

Override the catalog root via env var if your layout differs:

TEMPLATE_CATALOG_ROOT=/path/to/device_templates npm run dev

API Endpoints

The API provides several endpoints to manage this structure:

  • POST /topology: Accepts the full DTM (devices + buses[] + templates_used), persists it, and regenerates the AsyncAPI v3 spec + /topology/view projection + /topology/sld.svg (fetched from edp-api and cached). Called by platform-api at delivery time and on release rollout — not by the HMI.
  • Device capacity_kwh (optional, in POST /topology): an installed device's energy when its template can't know it — one CATL bank is the whole order's storage. Absent = the template's capacity_kwh. Every capacity sum (weighted_mean SoC rollups, the reserve floor, readiness, site capacity, the view's BESS pack) reads the device's value first.
  • GET /topology: Returns the full DTM including gateway-only fields (connection.host/port/unit_id, per-measurement binding). Consumed by platform-api and internal commissioning tooling. Not consumed by HMI.
  • GET /topology/view: Returns a sanitized DTM projection per system_adr §22. Strips connection.* and per-measurement binding; inlines per-template measurement metadata (unit, display_name_default, iec_61850_ref, bounds, thresholds, poll_rate_hz, enum values). HMI walks devices for parent chains and renders the module browser, routes, and analyst inputs from this view. devices[<id>].bounds carries ranges derived per device, overriding the template's for that measurement: a module whose set_active_power distributes gets bounds.active_power = the sum of its children's ranges (the same range the gateway clamps dispatch to), signed watts; null when nothing derives one. The same JSON without a server or database: npm run -s topology-view -- <dtm.json> > view.json (catalog root from cfg.yml; same schema and catalog checks as POST /topology).
  • GET /topology/sld.svg: Returns the SLD SVG generated by edp-api for this deployment, per system_adr §6. Regenerated on every POST /topology and §21 mutation; HMI fetches at boot and on system/topology_changed. Consumed by HMI.
  • GET /asyncapi: Returns the generated AsyncAPI v3 spec — channels + schemas + x-protocol-source (gateway bindings) + x-enum-values. Consumed by ems-industrial-gateway and ems-hmi (HMI ignores x-protocol-source).
  • x-heartbeat-source (in GET /asyncapi, present only when a device needs one): per device, a keep-alive counter the gateway writes 0 … counter_modulo-1 every period_s or the device's watchdog faults it (CATL's BMS may open its contactors). Declared on the template as heartbeat: { binding: <modbus_tcp write>, counter_modulo, period_s }; the entry is that binding plus the device's host/port/unit_id, merged like x-command-source. Never in /topology/view.
  • GET /loto: Lockout/tagout state — { locks, locked_devices }. locks are the active rows (one per person holding a device, oldest first); locked_devices is every locked device_id plus its DTM subtree, already expanded, sorted — the set the gateway refuses writes to and the HMI padlocks. Open, like /asyncapi: the gateway reads it at boot and holds no device-api credential.
  • GET /loto/history?device_id=: Every lock row ever written for one device, newest first. A device that left the DTM keeps its history.
  • POST /devices/{device_id}/loto: { holder_name, permit_ref? } with an operator bearer token → 201 the new row. 400 blank name · 401 no token · 403 viewer · 404 device not in DTM · 409 that holder already holds an active lock on it.
  • DELETE /loto/{id}: Clears exactly that row (operator token) → 200 the cleared row. 404 unknown · 409 already cleared. Nobody else's lock moves.
  • MQTT system/loto_changed { ts } (QoS 1, no retain) after every set/clear, after every DTM save (locked_devices expands over the DTM) and on every broker connect; consumers re-fetch GET /loto. LOTO is a state, not an alarm — nothing is acknowledged.

About

Device topology and AsyncAPI v3 topic provisioning

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages