Edge connector protocol
The Edge connector speaks the same envelope, hello, heartbeat and job
lifecycle as Axis agents and connectors (Agent and connector protocol),
and adds the messages and jobs of access control. The Go types in
entrosity-shared-go/proto/edge are the single source of truth; this page
describes intent and behaviour.
Like the rest of the protocol, these types only grow: fields are added, never renamed or repurposed, so connectors in the field keep working.
Transport
- WebSocket:
wss://hub.entrosity.com/edge/api/connector/v1/wswithAuthorization: Bearer <connector key>. JSON text frames, one envelope per frame, at most 1 MiB; the server pings every 30 seconds. - The first message must be
hello(capabilities: ["access_control", "edge_update"],pending_job_ids); the server answershello.ackand then delivers the pending jobs.edge_updatemeans the connector installs self-updates (Self-update); connectors without it are never offered one. Anything beforehellocloses the socket (1008). - The connector sends
heartbeatevery minute. It is online from itshellountil the socket closes, or after 3 minutes without a message. - Jobs follow
job.assign→job.ack→job.progress→job.result. A job sent withoutjob.ackwithin 60 seconds is sent again; a job not finished before its expiry becomestimeout. - Close codes:
4003the key was revoked (connector removed, tenant suspended);4009a newer connection of the same installation took over;1008policy violation.
HTTP endpoints (/api/connector/v1)
| Method and path | Purpose |
|---|---|
POST /enroll | {enrollment_token, hostname, domain, version} → {connector_id, connector_key, ws_url}. The token must be an Edge connector token. The same computer (tenant, host name, domain) enrolling again rotates its key and closes the old connection. Rate-limited per IP (EDGE_ENROLL_RATE_PER_MINUTE). |
GET /ws | The WebSocket. |
POST /heartbeat | HTTP fallback of heartbeat. |
GET /jobs/pending, POST /jobs/{id}/ack, …/progress, …/result | HTTP polling fallback of the job lifecycle. |
POST /events | HTTP fallback of edge.events: body an EventsChunk (gzip accepted; 4 MiB compressed, 8 MiB decoded), response the EventsAck. |
POST /controllers/status | HTTP fallback of edge.controller.status. |
GET /releases/{id}/msi?exp=&sig= | Download of a connector release's MSI, by the signed link in an update_agent job. No connector key: the link (valid 24 hours, signed with a key derived from EDGE_MASTER_KEY) is the authorization; an expired or altered link answers 403 download_link_invalid. |
All but /enroll and the release download need the connector key. Everything a connector does is
confined to its tenant.
Messages
Connector → server
| Type | Payload | When |
|---|---|---|
edge.events | {first_seq, events: [Event]}: 1–500 consecutive events (events[i].seq = first_seq + i) | Whenever the connector's queue has events. |
edge.controller.status | {controllers: [{controller_id, online, applied_version, applied_hash?, error?, doors?: [{index, open, locked}], info?: ControllerInfo}]} (up to 1,000 controllers) | At least every minute, and at once after a change. |
Server → connector
| Type | Payload | When |
|---|---|---|
edge.events.ack | {acked_seq}, in reply (reply_to) to edge.events | Every event up to acked_seq is stored; the connector drops them from its queue. |
Jobs
| Job | Payload | Result | Timeout / expiry | Connector lane |
|---|---|---|---|---|
edge.config.apply | {controller: ControllerRef, version, hash, snapshot: ConfigSnapshot} | {version, hash, cards} | 10 min / 7 days, priority 10 | config (one at a time) |
edge.controller.remove | {controller_id} | {} | 1 min / 30 days | config |
edge.door.open | {controller: ControllerRef, door_index, pulse_ms, requested_by?} | {} | 30 s / 30 s, priority 100 | door (4 in parallel) |
edge.discover | {driver, transport?, addresses?: ["host:port", …]} (up to 256; empty for the simulator lists every simulated controller) | {controllers: [ControllerInfo]} | 2 min / 5 min | discover (one at a time) |
edge.controller.test | {controller: ControllerRef} | ControllerInfo | 1 min / 5 min | discover |
update_agent | UpdateJob (component: edge-connector, see Self-update) | UpdateResult | 30 min / 24 h | update |
ControllerRefis{controller_id, driver, target};target(edge.Target) is{transport: "tcp"|"rs485", address, unit_id?, pin?}:addressishost:port, a serial device (COM3) or a Modbus gatewayhost:port;unit_idis the RS-485 bus address 1–31, absent on TCP.pin(uint32, 1–4294967294; omitted when unset) is the controller's PIN (TrackBase002: every command frame carries it). It is not part of the target's key (transport, address, unit id), so a new PIN is the same controller. Edge stores it encrypted and adds it when it delivers a job: job payloads stored in the database carry no PIN. The connector keeps it incontrollers.jsonand never sends it back:ControllerInfoin discovery and test results, and status reports, have it cleared.ControllerInfois{driver, target, model, serial, firmware, door_mode?}.edge.config.applymakes the controller hold exactly the snapshot. A new job for a controller cancels its older open apply job. A job older than the configuration the connector already applied (delivered late) succeeds with the newer version. The connector stores the last applied snapshot and restores it at start to controllers that lost it. A new PIN, address, transport or unit id is sent as an apply of the unchanged configuration (same version and hash): the connector only updates the target, with no full rewrite.edge.controller.removestops polling the controller and forgets its configuration. It is sent when a controller is removed or disabled; a disabled controller that is enabled again gets anedge.config.apply(forced), which registers it with the connector again.edge.door.openreleases the lock forpulse_ms(the door's unlock time; 3 s when absent). The controller reportsdoor_opened_remotewithrequested_byin the detail. Its short expiry means a door is never opened long after the request.
Self-update (update_agent)
The same job type and payload as for Axis agents and connectors
(Protocol), with
component: edge-connector:
{"release_id": "…", "component": "edge-connector",
"target": {"version": "1.4.0", "url": "https://…/api/connector/v1/releases/…/msi?exp=…&sig=…",
"sha256": "…", "size_bytes": 9437184, "signature": "…"},
"rollback": {"version": "1.3.2", "url": "…", "sha256": "…", "size_bytes": 9412608, "signature": "…"}}
| Field | Meaning |
|---|---|
release_id | Edge's id of the offered release. |
target | The MSI to install: version, url (a signed download link valid 24 hours, added when the job is delivered), sha256, size_bytes, and signature, the Ed25519 signature (base64) of "rmm-release-v1\n<component>\n<version>\n<sha256>\n<size>\n" with EDGE_RELEASE_SIGNING_KEY. |
rollback | The same for the version the connector runs, when Edge still has it: the watchdog reinstalls it if the new version does not start. |
JobResult.Result is an UpdateResult: {scheduled, from_version, to_version, rollback} (scheduled: the installer runs in a minute from
the scheduled task; rollback: a previous MSI is kept for the watchdog).
The job succeeds once the installation is scheduled; the new version shows
in the connector's next hello.
Edge offers an update when a connector says hello and every 5 minutes
(releases.rollout): the newest release of the connector's update channel
(stable: stable releases; beta: beta and stable) that is newer than the
connector's version, only to connectors that announce edge_update, not
while an update_agent job of the connector is open, and the same version
not again within an hour. What the connector does:
Edge connector → Self-update.
Job error codes
job.result.error_code of Edge jobs:
| Code | Meaning |
|---|---|
capacity_exceeded | The snapshot has more cards than the controller holds, or the controller's card memory is full. |
controller_unreachable | The controller did not answer. |
config_rejected | The controller, or the connector's check of the snapshot, refused the configuration. |
unsupported | The driver cannot reach the controller over this connection (trackbase002 over RS-485/serial). |
unknown_driver | This connector build has no driver of that name. |
invalid_payload | The payload could not be decoded or is invalid. |
exec_failed | The connector restarted before the job finished; for update_agent, the update folder or scheduled task could not be created. |
update_unsigned | update_agent: the build has no release public key and refuses updates. |
signature_invalid | update_agent: a release signature did not verify. |
download_failed, hash_mismatch | update_agent: the MSI could not be downloaded, or does not match the release's SHA-256 and size. |
Configuration snapshot
{
"timezone": "Europe/Sofia",
"door_mode": "two_unidirectional",
"doors": [{"index": 1, "lock_relay": 1, "open_pulse_ms": 3000, "held_open_seconds": 30}],
"readers": [{"channel": "wiegand1", "door_index": 1, "direction": "in"}],
"schedules": [{"slot": 1, "name": "Office", "intervals": [{"day": 0, "start": 480, "end": 1080}]}],
"holidays": ["2026-12-25"],
"cards": [{"kind": "wiegand26", "facility_code": 12, "card_number": 3456,
"card_id": "…", "grants": [{"door_index": 1, "schedule_slot": 1}]}]
}
| Field | Meaning |
|---|---|
timezone | IANA time zone the schedules and holidays are expressed in: the site's, else the tenant's default, else UTC. |
door_mode | one_bidirectional (one door, entry and exit reader) or two_unidirectional (two doors, one reader each). |
doors | Door index 1–2, lock_relay 1–4, open_pulse_ms, held_open_seconds (0: never raise door_held_open). |
readers | Enabled readers only: channel (wiegand1, wiegand2, ibutton1, ibutton2), the door it serves, direction (in, out). |
schedules | The schedules the controller's doors use, numbered slot 1–255. intervals are [start, end) in minutes since midnight; day 0 = Monday … 6 = Sunday, 7 = holiday windows. |
holidays | Dates (YYYY-MM-DD, in timezone) from the day before up to 90 days ahead, on which the holiday windows apply instead of the weekday's. |
cards | Every card that may pass at least one of the doors (at most 2,000): the credential (wiegand26 with facility_code 0–255 and card_number 0–65535, or ibutton with ibutton_id, 2–16 hex digits), Edge's card_id, and its grants (door and schedule slot). |
hash is the SHA-256 (hex) of the snapshot's JSON; every list is ordered
deterministically, so the same configuration always has the same hash. The
connector reports the hash the controller holds in applied_hash; a
different hash on an online controller that is not syncing makes the
server send the configuration again.
The reference decision (ConfigSnapshot.Allows, implemented by the
drivers and the simulator): an unknown credential → unknown_card; no
grant for the door → access_denied/no_access; a grant whose schedule
has an open window now (holiday windows on a holiday) → access_granted;
otherwise access_denied with holiday on a holiday, else
outside_schedule.
Events
{"seq": 1042, "controller_id": "…", "occurred_at": "2026-09-28T06:00:12Z",
"type": "access_granted", "door_index": 1, "reader_channel": "wiegand1",
"direction": "in", "credential": {"kind": "wiegand26", "facility_code": 12, "card_number": 3456}}
| Field | Meaning |
|---|---|
seq | Assigned by the connector's queue: strictly increasing per connector installation. |
controller_id, occurred_at, type | Required. Types: access_granted, access_denied, unknown_card, door_opened_remote, door_forced, door_held_open, door_closed, controller_online, controller_offline, config_applied, tamper. |
door_index | 1–2; absent for controller events. |
reader_channel, direction | The reader, for badge events. |
credential | The card read. |
reason | For access_denied: no_access, outside_schedule, holiday. |
detail | Free text, at most 512 characters (who opened a door remotely, driver messages). |
Exactly once: the server keeps, per connector, the highest sequence number it stored. A chunk at or below it is a duplicate and only acknowledged; the part of a chunk above it is stored and the cursor moved, in one transaction. The connector keeps events on disk until they are acknowledged, so a lost acknowledgement, a restart or an outage only causes a resend. Over the WebSocket, a chunk without acknowledgement within 15 seconds is sent again over HTTP.
On ingest the server resolves the controller, door, reader, card and
cardholder of each event (history keeps them even after deletions) and
streams the new entries to the web app. occurred_at more than 24 hours in
the future or more than a year in the past is replaced by the time of
arrival, with a note in the detail.
controller_online and controller_offline are recorded by the server
from edge.controller.status transitions.
Limits
| Limit | Value |
|---|---|
| Cards per controller | 2,000 |
| Schedules per controller (slots) | 255 |
| Doors per controller, relays | 2, 4 |
| RS-485 bus addresses | 1–31 |
Events per edge.events chunk | 500 (the connector also keeps a chunk under 256 KiB) |
| Controllers per status report | 1,000 |
| WebSocket message | 1 MiB |
| Addresses per discovery | 256 |