13 — Bridge & Devices

Bridge WebSocket, device discovery, printers, C# bridge (reference expanded)

CEO Summary

The Bridge is the on-site integration agent: it connects our cloud to the tenant’s LAN so we can drive printers, kitchen displays, optional crypto nodes, device awareness, and signage refresh signals—without opening inbound firewall holes. The bridge always dials out to our server over WebSocket. Business value: Hybrid retail and hospitality: cloud POS plus reliable local peripherals and in-venue screens. Key metrics: WebSocket connected, heartbeat age, print/KDS success. If this breaks: Cloud features that depend on LAN routing fail or degrade; many POS flows still work in-browser, but local print/KDS and bridge-mediated actions stop until the tunnel returns. Signage playback still works from the cloud player URL without the bridge; LAN-side auto-refresh may pause until reconnect.

Bridge in the customer’s stack

The bridge sits at the edge of the tenant network (typically a small Windows PC or VM on the same subnet as printers and KDS). Browsers and tablets talk to our cloud over HTTPS as usual. When the cloud needs something on the LAN, it sends a message down the existing WebSocket to the bridge; the bridge executes locally (spool print jobs, fire KDS tickets, optional RPC to a local crypto node, etc.).

flowchart LR subgraph Cloud["Cloud (this repo)"] API["REST API"] WSS["WebSocket /api/bridges/connect"] BCM["bridgeConnectionManager"] API --> BCM BCM --> WSS end subgraph Site["Customer site"] BR["PAWSPOS Bridge (C#)"] LAN["Printers, KDS, devices"] BR --> LAN end BR -->|"outbound WS + TLS"| WSS Browsers["Staff browsers / POS"] -->|"HTTPS"| API

WAN failover, offline, and “Starlink if AT&T dies”

WAN redundancy (primary fiber/cable vs Starlink, dual ISP, SD-WAN) is normally handled by the customer’s router, firewall, or SD-WAN appliance—not by the bridge application. When the public path to our API changes or comes back, the bridge’s existing reconnect and retry logic (see C# ServerConnectionService) should re-establish the WebSocket automatically once DNS and routing stabilize.

Offline operations: True offline POS (queue sales locally, sync later) is implemented in the browser SPA via IndexedDB (pawspos_offline) and posApi / idempotent replay — see 14 — Frontend and 06 — POS & Invoicing. The Node.js bridge (bridge/) attaches the same style of mutation envelope headers on mutating axios calls toward the cloud API (Idempotency-Key, X-PPO-*) so bridge-originated writes dedupe consistently with web POS. On the C# bridge side, OfflineQueueService periodically attempts to drain a local SQLite queue when cloud sync is authenticated; that path remains largely scaffolding (verify before relying on it).

Failsafe operations: If the bridge process stops, LAN routing from the cloud stops until it restarts; mitigations are monitoring, auto-restart (Windows Service / PM2-style supervisor), and redundant bridge host if the site requires it. Peripheral-level failsafe (e.g. backup printer) remains local policy.

Cloud server: WebSocket and manager

Mount: WebSocketServer in backend/server.js, path /api/bridges/connect. Logic: backend/services/bridgeConnectionManager.js.

Query parameters: bridgeId, tenantSlug (required); bridgeToken (optional, not yet enforced server-side).

On connect, the server validates the tenant exists and is active, then sends { type: "welcome", bridgeId, message }.

Method / areaRole
registerWebSocketServer(wss)Attaches connection lifecycle, ping interval, message parsing
handleMessage(bridgeId, message)Handles bridge → cloud: register, heartbeat, ping/pong, response
sendRequest(tenantSlug, orgId, action, data, timeout)Sends { type: "request", requestId, action, data }; waits for matching response
getBridgeStatus / getBridgeByTenant / getBridgeByIdStatus and connection lookup with org isolation
getAllBridgesList connected bridges (admin/ops)
trackDevice / getActiveDevicesIn-memory map of recent device notifications per bridge (cleared on disconnect); action exit or register_closed removes the device key; exposed in getBridgeStatus as activeDevices

REST API (tenant-authenticated)

Mounted under the API prefix (e.g. /api). All routes use requireSessionAuth and tenantResolver unless noted.

MethodPathPurpose
GET/bridge/statusWebSocket bridge status if connected (includes activeDevices from notify-device tracking); else optional HTTP fallback to tenant_configs.bridge_url (/health, /info)
POST/bridge/configSave bridge_url (legacy HTTP path)
POST/bridge/testTest reachability of bridge URL (/health)
POST/bridge/tokenGenerate and store bridge_token in tenant_configs
GET/bridge/tokenReturn whether a token exists (not the secret value)
GET/bridge/config-fileDownload suggested appsettings.json for the C# bridge
POST/bridge/notify-devicePush device enter/exit to bridge via WebSocket; updates server-side active-device map
POST/bridge/notify-signagePush signageUpdate with action: manualRefresh; optional body displaySlug, reason. Session + tenant.

WebSocket protocol (summary)

Bridge → cloud (inbound to server)

typeMeaning
registerUpdates metadata: bridgeName, localIP, platform, version, device/printer counts
heartbeatPeriodic status; refreshes lastHeartbeat and counts
pingApplication-level; server replies { type: "pong" }
pongAck to server-initiated keepalive
responseReply to a prior request (includes requestId, success, data or error)

Cloud → bridge

Requests are JSON messages: { "type": "request", "requestId", "action", "data" }. The C# client may also accept a top-level deviceNotification message type for the same payload shape.

actionTypical dataBridge behavior (C#)
getStatusReturns bridge id/name/local IP, device list, printer list
printprinterId, jobType, data (base64)Queues job via PrinterRoutingService
fireTicketorderId, invoiceId, station, items[]KDSService.FireTicketAsync
cryptoRpcsymbol, method, parametersCryptoNodeService.ExecuteRpcAsync
getWalletsLists wallets via WalletStorageService
deviceNotificationdeviceKey, deviceType, mode, action (enter/exit/…), optional staff, shiftIdUpdates bridge-side ServerTrackedDevice map

Sequence: print / KDS from cloud

sequenceDiagram participant Cloud as Cloud API participant BCM as bridgeConnectionManager participant WS as WebSocket participant Bridge as C# Bridge participant LAN as Printer / KDS Cloud->>BCM: sendRequest(..., action, data) BCM->>WS: request + requestId WS->>Bridge: JSON message Bridge->>LAN: spool / fire ticket Bridge-->>WS: response + requestId WS-->>BCM: resolve Promise BCM-->>Cloud: data or error

C# bridge (bridge-win-csharp/PAWSPOS.Bridge)

Services (inventory)

ServiceRole
ServerConnectionServiceOutbound WebSocket client, heartbeats, ping timer, dispatches request actions
ConfigServiceReads appsettings.json (URLs, tenant, bridge id, token)
DatabaseServiceLocal SQLite (devices, printers, offline queue, etc.)
DeviceDiscoveryServiceLAN discovery cadence
PrinterRoutingServicePrint queue to physical printers
KDSServiceKitchen ticket lifecycle
CloudSyncServiceAuthentication / sync toward cloud
OfflineQueueServiceTimer-driven queue sync attempt (implementation incomplete)
CryptoNodeService / WalletStorageServiceOptional local chain RPC and wallet metadata
WebSocketServiceLocal HTTP upgrade /ws for LAN clients (signage relay, scripts); relays cloud-originated JSON such as signageUpdate to connected browsers
ProxyServiceComposition shell (currently minimal)

HTTP controllers under the bridge host (e.g. BridgeController, HomeController) support legacy health/info endpoints used when bridge_url HTTP fallback is enabled.

Tenant configuration

Table tenant_configs (per-tenant DB): bridge_url, bridge_token. Routes ensure the table exists. Generated appsettings uses API_BASE_URL or defaults to staging API.

Source files (this repo)

Signage: cloud → bridge (high level)

flowchart LR subgraph Cloud SR[signage.routes.js] BCM[pushEnvelope] end subgraph Site BR2[Bridge process] LP[Local players / scripts] end SR -->|after mutating folders/ads/displays| BCM BCM -->|WebSocket JSON| BR2 BR2 --> LP

Detail: 26 — Signage & Displays.

Device routing & discovery (flows)

Goal: Staff see registers, kiosks, and KDS-style endpoints in Admin → Devices (/admin/devices/manager). Rows live in the tenant Devices table. There is no magic browser LAN scan of every device on Wi‑Fi — visibility comes from heartbeats to the cloud API (while a user session exists), manual CRUD, and bridge-side plumbing (local DB, WebSocket notify-device, signage WS clients).

1) Where device truth shows up

flowchart TB subgraph DR_MySQL["Tenant MySQL"] DR_DEV["Devices table"] end subgraph DR_Cloud["Cloud API"] DR_HB["POST /api/devices/heartbeat"] DR_ADM["GET /api/admin/devices"] end subgraph DR_Dash["Dashboard"] DR_DMGR["Device manager UI"] end subgraph DR_Browser["Staff browser POS"] DR_SVC["deviceHeartbeat.service.js"] end DR_DMGR -->|"session cookie"| DR_ADM DR_ADM --> DR_DEV DR_SVC -->|"axios + session"| DR_HB DR_HB --> DR_DEV

2) Browser heartbeat sequence (auto-register)

While POS is open and the user is logged in, the SPA posts JSON on an interval. deviceKey is stable in localStorage. Local IP is best-effort (WebRTC and optional manual deviceLocalIP) — not guaranteed across browsers or locked-down networks.

sequenceDiagram participant POS as POS browser tab participant HB as deviceHeartbeat.service participant API as POST /api/devices/heartbeat participant DB as Tenant Devices POS->>HB: interval tick HB->>HB: read deviceKey localStorage HB->>HB: optional WebRTC local IP HB->>API: axios with session cookie API->>DB: upsert by device_key org_id API-->>HB: deviceId success

3) Cloud ↔ bridge: live register awareness (not the same as SQL rows)

POST /api/bridge/notify-device pushes a deviceNotification request down the bridge WebSocket when the cloud wants the bridge to know a register entered or exited POS. The bridge connection manager keeps a short-lived in-memory map (trackDevice), surfaced in GET /api/bridge/status as activeDevices. That list is for ops and bridge diagnostics; it does not replace the Devices table unless the heartbeat path also wrote the row.

sequenceDiagram participant UI as Dashboard or POS participant API as Cloud REST participant BCM as bridgeConnectionManager participant WS as Bridge WebSocket participant BR as On-site bridge UI->>API: POST /api/bridge/notify-device API->>BCM: trackDevice plus sendRequest BCM->>WS: deviceNotification JSON WS->>BR: outbound tunnel BR-->>WS: optional response

4) On-site bridge: LAN heartbeat endpoint (Node / C#)

The Node bridge exposes POST /local/devices/heartbeat for LAN agents. It forwards the body toward the cloud as POST …/devices/heartbeat with a Bearer token after bridge authentication. The public router for /api/devices applies cookie session auth to those routes — so server-to-server bridge forwarding may receive 401 until a bridge-scoped ingest path exists (for example a dedicated POST /api/bridges/… handler or optional Bearer validation on heartbeat). Today, the path into Devices that matches the dashboard session model is the logged-in browser heartbeat.

5) Signage and other LAN clients

Signage relay players use the bridge’s local /ws relay. They are not inserted into Devices by default — they consume playlists. If you need them listed as devices, use the same heartbeat pattern as other LAN clients once bridge-to-cloud device ingest is aligned with auth, or register them manually in Admin → Devices.

Device registry, metadata, and POS vertical modules

Where registers are defined: Tenant staff configure devices in the dashboard at /admin/devices/manager (frontend/src/pages/admin/sections/devices/DeviceManager.jsx) — create/edit rows in the tenant Devices table (type, location, name, device_key, JSON metadata). This is not the Business Hub (/business-hub) settings search; forecourt/fuel and similar toggles live on the device record.

POS modules (verticals): Optional Modern POS panels are gated by metadata.posModules (alias pos_modules). The server normalizes module IDs in backend/utils/posModules.js. Today only fuel is recognized: { "posModules": ["fuel"] } on the POS device row. Admin: Forecourt / fuel module checkbox; table shows Fuel and optional pump count under POS extras.

Forecourt site config (fuelSite): Stored as metadata.fuelSite (legacy fuel_site read on parse; canonical key on save). Sanitized in backend/utils/fuelSite.js; admin device POST/PUT runs normalizeDeviceMetadataFuelSite. Fields: authorizationMode (pos | attendant | fms_monitor); pumps[] (id, label, hoses 1-12); grades[] (code, label, optional tankId, productSku); integration (type: none | site_bridge | third_party_fms, vendor, notes); posPanel (maxPumpsVisible, showAllStop, showTankAlarmsShortcut). Admin form: FuelSiteFormSection.jsx inside DeviceManager.jsx.

Runtime: GET /api/pos/settings (06 — POS & Invoicing) returns device.posModules and device.fuelSite via parseFuelSiteFromDeviceRow. ModernPOS.jsx passes both to PosModuleHost / FuelModulePanel. Cart line metadata for fuel: see ch. 06.

flowchart TB subgraph AdminUI["Admin UI"] DM[DeviceManager + FuelSiteFormSection] end subgraph TenantDB["Tenant DB"] DevRow[(Devices + metadata)] end subgraph API["Express API"] PS["GET /api/pos/settings"] PM[posModules.js] FS[fuelSite.js] NORM[normalizeDeviceMetadataFuelSite on save] end subgraph POS["Modern POS"] MP[ModernPOS.jsx] Host[PosModuleHost] Fuel[FuelModulePanel] end DM -->|posModules + fuelSite| DevRow NORM --> DevRow PS --> DevRow PS --> PM PS --> FS PM -->|posModules| MP FS -->|fuelSite| MP MP --> Host Host --> Fuel

Related platform routes

Devices: POST /api/devices/heartbeat, /api/admin device CRUD (devices router), and device catalog flows (see route appendix)—orthogonal to the bridge WebSocket but part of “who is on the floor.” See Device routing & discovery above for flowcharts (browser heartbeat vs bridge notify vs signage). Notify bridge: POST /api/bridge/notify-device ties POS/session events to the bridge’s view of active registers. Signage: /api/signage/* and POST /api/bridge/notify-signage.

Bridge implementations

FolderNotes
bridge-win-csharp/Primary documented bridge (.NET 8)
bridge/, bridge-win/Node variants; legacy / alternate deployments