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, dlr-operating-envelope, and ems-hmi. Topic shape, payload schemas, unit vocabulary, and versioning are governed by ems/topic_structure_adr.md.
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 noteparticipant 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 createdparticipant 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-sourceextension carries Modbus/SNMP/etc. bindings for the gateway. Consumed byems-industrial-gateway,dlr-operating-envelope, andems-hmi(HMI ignoresx-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-measurementbinding). Consumed byplatform-apiand internal commissioning tooling. Not consumed byems-hmi— those fields don't belong in customer browsers.GET /topology/view— sanitized projection of the DTM per system_adr §22. Stripsconnection.*and per-measurementbinding; inlines per-template measurement metadata (unit,display_name_default,iec_61850_ref,bounds,thresholds,poll_rate_hz, enumvalues) that HMI needs. Consumed byems-hmi.
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/modelinaagainst 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 fetchGET /asyncapiat 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 SVGPer 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.
Compose service block:
device-api:
environment:
BOOT_DTM_PATH: /app/dtm.json
volumes:
- /opt/arcnode/dtm.json:/app/dtm.json:roEC2 UserData curls https://arcnode-public/orders/<id>/dtm.json to /opt/arcnode/dtm.json before docker compose up.
Same compose block. ISO bake step writes the file to /opt/arcnode/dtm.json.
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.
Omit BOOT_DTM_PATH. Service boots empty; tests POST DTMs explicitly via tests/topology.test.ts patterns.
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.
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_templatesOverride the catalog root via env var if your layout differs:
TEMPLATE_CATALOG_ROOT=/path/to/device_templates npm run devThe 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/viewprojection +/topology/sld.svg(fetched from edp-api and cached). Called byplatform-apiat delivery time and on release rollout — not by the HMI. - GET /topology: Returns the full DTM including gateway-only fields (
connection.host/port/unit_id, per-measurementbinding). Consumed byplatform-apiand internal commissioning tooling. Not consumed by HMI. - GET /topology/view: Returns a sanitized DTM projection per system_adr §22. Strips
connection.*and per-measurementbinding; inlines per-template measurement metadata (unit,display_name_default,iec_61850_ref,bounds,thresholds,poll_rate_hz, enumvalues). HMI walksdevicesfor parent chains and renders the module browser, routes, and analyst inputs from this view. - GET /topology/sld.svg: Returns the SLD SVG generated by edp-api for this deployment, per system_adr §6. Regenerated on every
POST /topologyand §21 mutation; HMI fetches at boot and onsystem/topology_changed. Consumed by HMI. - GET /asyncapi: Returns the generated AsyncAPI v3 spec — channels + schemas +
x-protocol-source(gateway bindings) +x-enum-values. Consumed byems-industrial-gateway,dlr-operating-envelope, andems-hmi(HMI ignoresx-protocol-source).