Skip to content

feat(peripheral-demo): add Edge App for peripheral integration#719

Open
nicomiguelino wants to merge 55 commits intomasterfrom
feat/peripheral-integration-poc
Open

feat(peripheral-demo): add Edge App for peripheral integration#719
nicomiguelino wants to merge 55 commits intomasterfrom
feat/peripheral-integration-poc

Conversation

@nicomiguelino
Copy link
Contributor

@nicomiguelino nicomiguelino commented Mar 2, 2026

Summary

  • Add peripheral-integration-demo Edge App demonstrating multi-screen role-based display (public, operator, maintenance) driven by live peripheral data
  • Add createPeripheralClient() to edge-apps-library — a WebSocket client that connects to the hardware integration service and delivers sensor state via watchState(callback)
  • Add startPeripheralMockServer() to the Vite dev-server plugin, simulating the peripheral WebSocket protocol on port 9010 for local development
  • Wire ambient temperature readings into the public screen display
  • Wire NFC card reads into role-based screen switching
  • Add SourceChannelStateReport and EdgeAppSourceState types to edge-apps-library
  • Exclude font files from asset inlining to prevent false positives during deployment

Notes

  • The WebSocket connection is lazy — it only starts when register() or watchState() is first called, so existing apps in dev mode are unaffected
  • If port 9010 is already in use (e.g. real hardware is connected), the mock server silently skips startup
  • Font assets are now emitted as separate files; other assets (e.g. images) continue to be inlined up to 7 MB

Screenshots

Screenshot 2026-03-04 at 4 51 13 PM image image

- Add `peripheral-integration-poc` Edge App displaying live sensor data
- Add `startPeripheralMockServer()` to dev-server plugin (WebSocket on port 9010)
- Inject `screenly.peripherals.subscribe()` IIFE into generated `screenly.js`
- Add `PeripheralEvent` and `ScreenlyPeripherals` types to edge-apps-library
- Add `ws` and `@types/ws` as dev dependencies to edge-apps-library
- Add `POC.md` documenting the protocol, API design, and rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

PR Reviewer Guide 🔍

(Review updated until commit ab7323a)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Protocol Robustness

The WebSocket framing/parsing looks optimistic: messages are trimmed with a single ETB replace and then JSON-parsed, which may fail if multiple frames arrive in one event, if ETB appears more than once, or if partial frames are received. Consider a small frame buffer/split-on-ETB approach (and using a safer trim like removing only trailing ETB), plus validating expected shapes before accessing nested properties like request IDs and state arrays.

function send(payload: unknown) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(payload) + ETB)
  }
}

function notifySubscribers() {
  const msg: PeripheralStateMessage = {
    request: {
      id: ulid(),
      edge_app_source_state: {
        states: Object.values(
          readings,
        ) as PeripheralStateMessage['request']['edge_app_source_state']['states'],
      },
    },
  }
  subscribers.forEach((cb) => cb(msg))
}

function connect() {
  ws = new WebSocket(PERIPHERAL_WS_URL)

  ws.onopen = () => {
    send({
      request: {
        id: ulid(),
        identification: { node_id: ulid(), description: 'Edge App' },
      },
    })
  }

  ws.onmessage = (e: MessageEvent) => {
    const text = (e.data as string).replace(ETB, '')
    let msg: Record<string, unknown>
    try {
      msg = JSON.parse(text) as Record<string, unknown>
    } catch {
      return
    }

    const response = msg.response as Record<string, unknown> | undefined
    if (
      response?.ok !== undefined &&
      (response.ok as Record<string, unknown>)?.identification !== undefined
    ) {
      identified = true
      return
    }

    const request = msg.request as Record<string, unknown> | undefined
    if (request?.edge_app_source_state) {
      const state = request.edge_app_source_state as {
        states: Array<{ name: string }>
      }
      state.states.forEach((s) => {
        readings[s.name] = s
      })
      notifySubscribers()
      send({
        response: { request_id: request.id, ok: 'edge_app_source_state' },
      })
      return
    }

    if (request?.downstream_node_event) {
      send({
        response: { request_id: request.id, ok: 'downstream_node_event' },
      })
      return
    }
  }
Lifecycle Leak

Subscriber management has no unsubscribe and the internal subscribers array can grow unbounded (especially across screen/app re-inits). Also, register adds a message event listener per call until identification completes; repeated calls before identification could accumulate listeners. It would be safer to return an unsubscribe function from watchState, dedupe callbacks, and ensure one-time listeners are truly one-time even under reconnects.

// eslint-disable-next-line max-lines-per-function
export function createPeripheralClient(): PeripheralClient {
  let ws: WebSocket | null = null
  let identified = false
  const subscribers: Array<(msg: PeripheralStateMessage) => void> = []
  const readings: Record<string, unknown> = {}

  function send(payload: unknown) {
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(payload) + ETB)
    }
  }

  function notifySubscribers() {
    const msg: PeripheralStateMessage = {
      request: {
        id: ulid(),
        edge_app_source_state: {
          states: Object.values(
            readings,
          ) as PeripheralStateMessage['request']['edge_app_source_state']['states'],
        },
      },
    }
    subscribers.forEach((cb) => cb(msg))
  }

  function connect() {
    ws = new WebSocket(PERIPHERAL_WS_URL)

    ws.onopen = () => {
      send({
        request: {
          id: ulid(),
          identification: { node_id: ulid(), description: 'Edge App' },
        },
      })
    }

    ws.onmessage = (e: MessageEvent) => {
      const text = (e.data as string).replace(ETB, '')
      let msg: Record<string, unknown>
      try {
        msg = JSON.parse(text) as Record<string, unknown>
      } catch {
        return
      }

      const response = msg.response as Record<string, unknown> | undefined
      if (
        response?.ok !== undefined &&
        (response.ok as Record<string, unknown>)?.identification !== undefined
      ) {
        identified = true
        return
      }

      const request = msg.request as Record<string, unknown> | undefined
      if (request?.edge_app_source_state) {
        const state = request.edge_app_source_state as {
          states: Array<{ name: string }>
        }
        state.states.forEach((s) => {
          readings[s.name] = s
        })
        notifySubscribers()
        send({
          response: { request_id: request.id, ok: 'edge_app_source_state' },
        })
        return
      }

      if (request?.downstream_node_event) {
        send({
          response: { request_id: request.id, ok: 'downstream_node_event' },
        })
        return
      }
    }

    ws.onerror = () => console.warn('[screenly] Peripheral WS error')
    ws.onclose = () => {
      identified = false
      setTimeout(connect, 2000)
    }
  }

  return {
    register(edgeAppId: string) {
      if (!ws) connect()
      const sendRegistration = () => {
        send({
          request: {
            id: ulid(),
            edge_app_registration: {
              id: edgeAppId,
              secret: '',
              requested_source_channels: [],
            },
          },
        })
      }
      if (ws!.readyState === WebSocket.OPEN && identified) {
        sendRegistration()
      } else {
        ws!.addEventListener('message', function onIdentified() {
          if (identified) {
            sendRegistration()
            ws!.removeEventListener('message', onIdentified)
          }
        })
      }
    },

    watchState(callback: (msg: PeripheralStateMessage) => void) {
      if (!ws) connect()
      subscribers.push(callback)
    },
  }
Dev WS Behavior

startPeripheralMockServer() is started unconditionally and logs “listening” even if the port is already in use; depending on how ws behaves, this can produce misleading output. Also the comment says “every 5 seconds” but the interval is 10s, and intervals/servers aren’t tied into Vite server shutdown hooks (potential port contention on rapid restarts). Consider gating the startup, logging only after successful listen, and cleaning up on server close.

const PERIPHERAL_WS_PORT = 9010
const ETB = '\x17'

type SensorType =
  | 'ambient_temperature'
  | 'humidity'
  | 'air_pressure'
  | 'secure_card'

const SENSOR_META: Record<
  SensorType,
  { channelName: string; unit: string | null }
> = {
  ambient_temperature: { channelName: 'my_living_room_temp', unit: '°C' },
  humidity: { channelName: 'room_humidity', unit: '%' },
  air_pressure: { channelName: 'room_pressure', unit: 'hPa' },
  secure_card: { channelName: 'ew_demo_nfc_reader', unit: null },
}

const MOCK_CARD_UIDS = {
  operator: 'yHSl7w',
  maintenance: 'mK3pXq',
}

function makeMockSensorValue(
  sensor: SensorType,
): number | Record<string, string> {
  switch (sensor) {
    case 'ambient_temperature':
      return parseFloat((20 + Math.random() * 10).toFixed(2))
    case 'humidity':
      return parseFloat((40 + Math.random() * 40).toFixed(2))
    case 'air_pressure':
      return parseFloat((1000 + Math.random() * 30).toFixed(2))
    case 'secure_card':
      return {
        uid:
          Math.random() < 0.5
            ? MOCK_CARD_UIDS.operator
            : MOCK_CARD_UIDS.maintenance,
      }
  }
}

function buildStateSnapshot(): Record<string, unknown>[] {
  return (
    Object.entries(SENSOR_META) as [
      SensorType,
      { channelName: string; unit: string | null },
    ][]
  ).map(([wireKey, meta]) => {
    const reading: Record<string, unknown> = {
      name: meta.channelName,
      [wireKey]: makeMockSensorValue(wireKey),
      timestamp: Date.now(),
    }
    if (meta.unit) reading.unit = meta.unit
    return reading
  })
}

// eslint-disable-next-line max-lines-per-function
function startPeripheralMockServer(): void {
  const wss = new WebSocketServer({ port: PERIPHERAL_WS_PORT })

  // eslint-disable-next-line max-lines-per-function
  wss.on('connection', (ws) => {
    let identified = false

    ws.on('message', (raw: Buffer) => {
      const text = raw.toString().replace(ETB, '')
      let msg: Record<string, unknown>
      try {
        msg = JSON.parse(text)
      } catch {
        return
      }

      const req = msg.request as Record<string, unknown> | undefined
      if (!req) return

      const requestId = req.id as string

      // Identification handshake
      if (req.identification) {
        identified = true
        const ack =
          JSON.stringify({
            response: { request_id: requestId, ok: { identification: null } },
          }) + ETB
        ws.send(ack)
        // Push full state snapshot immediately after identification
        const initialSnapshot =
          JSON.stringify({
            request: {
              id: ulid(),
              edge_app_source_state: { states: buildStateSnapshot() },
            },
          }) + ETB
        ws.send(initialSnapshot)
        return
      }

      if (!identified) return

      // Registration is fire-and-forget — no response needed
      if (req.edge_app_registration) return

      // GetState request
      const channelName = req.source_channel_get_state as string | undefined
      const sensorEntry = channelName
        ? (
            Object.entries(SENSOR_META) as [
              SensorType,
              { channelName: string; unit: string | null },
            ][]
          ).find(([, meta]) => meta.channelName === channelName)
        : undefined
      if (sensorEntry) {
        const [wireKey, meta] = sensorEntry
        const value = makeMockSensorValue(wireKey)
        const reading: Record<string, unknown> = {
          name: channelName,
          [wireKey]: value,
          timestamp: Date.now(),
        }
        if (meta.unit) reading.unit = meta.unit
        const response =
          JSON.stringify({
            response: {
              request_id: requestId,
              ok: { source_channel_get_state: reading },
            },
          }) + ETB
        ws.send(response)
      }
    })

    // Push full state snapshot every 5 seconds
    const interval = setInterval(() => {
      if (!identified || ws.readyState !== ws.OPEN) return
      const event =
        JSON.stringify({
          request: {
            id: ulid(),
            edge_app_source_state: { states: buildStateSnapshot() },
          },
        }) + ETB
      ws.send(event)
    }, 10000)

    ws.on('close', () => clearInterval(interval))
  })

  wss.on('error', (err: NodeJS.ErrnoException) => {
    if (err.code === 'EADDRINUSE') {
      console.warn(
        `[screenly-dev-server] Port ${PERIPHERAL_WS_PORT} already in use — peripheral mock not started.`,
      )
    } else {
      console.error('[screenly-dev-server] Peripheral WS error:', err.message)
    }
  })

  console.log(
    `[screenly-dev-server] Peripheral mock WS server listening on ws://127.0.0.1:${PERIPHERAL_WS_PORT}`,
  )
}

@nicomiguelino
Copy link
Contributor Author

@514sid, this PR is a proof of concept. Please check the TL;DR comment in this PR for reference.

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

PR Code Suggestions ✨

Latest suggestions up to ab7323a

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix WebSocket readyState check

ws.OPEN is a static constant in ws and is typically not available on the instance,
so this condition will almost always short-circuit and prevent periodic snapshots.
Compare readyState against the numeric OPEN value instead to ensure pushes actually
happen.

edge-apps/edge-apps-library/vite-plugins/dev-server.ts [191-201]

 const interval = setInterval(() => {
-  if (!identified || ws.readyState !== ws.OPEN) return
+  // In `ws`, OPEN is `1` (static constant on the class, not reliably on the instance).
+  if (!identified || ws.readyState !== 1) return
   const event =
     JSON.stringify({
       request: {
         id: ulid(),
         edge_app_source_state: { states: buildStateSnapshot() },
       },
     }) + ETB
   ws.send(event)
 }, 10000)
Suggestion importance[1-10]: 8

__

Why: The check ws.readyState !== ws.OPEN is likely always true because OPEN is not reliably present on the ws instance, which would prevent periodic snapshot pushes entirely. Comparing to the actual open state (or a proper constant) fixes a functional bug in the peripheral mock server.

Medium
Handle ETB message framing

WebSocket frames can contain multiple ETB-delimited JSON messages (or partial
messages), and replace(ETB, '') only removes the first delimiter. Split by ETB and
process each complete chunk to avoid dropping updates or failing JSON parsing when
multiple messages arrive together.

edge-apps/edge-apps-library/src/peripherals/index.ts [55-94]

 ws.onmessage = (e: MessageEvent) => {
-  const text = (e.data as string).replace(ETB, '')
-  let msg: Record<string, unknown>
-  try {
-    msg = JSON.parse(text) as Record<string, unknown>
-  } catch {
-    return
-  }
+  const raw = String(e.data ?? '')
+  const chunks = raw.split(ETB).filter(Boolean)
 
-  const response = msg.response as Record<string, unknown> | undefined
-  if (
-    response?.ok !== undefined &&
-    (response.ok as Record<string, unknown>)?.identification !== undefined
-  ) {
-    identified = true
-    return
-  }
+  for (const text of chunks) {
+    let msg: Record<string, unknown>
+    try {
+      msg = JSON.parse(text) as Record<string, unknown>
+    } catch {
+      continue
+    }
 
-  const request = msg.request as Record<string, unknown> | undefined
-  if (request?.edge_app_source_state) {
-    const state = request.edge_app_source_state as {
-      states: Array<{ name: string }>
+    const response = msg.response as Record<string, unknown> | undefined
+    if (
+      response?.ok !== undefined &&
+      (response.ok as Record<string, unknown>)?.identification !== undefined
+    ) {
+      identified = true
+      continue
     }
-    state.states.forEach((s) => {
-      readings[s.name] = s
-    })
-    notifySubscribers()
-    send({
-      response: { request_id: request.id, ok: 'edge_app_source_state' },
-    })
-    return
-  }
 
-  if (request?.downstream_node_event) {
-    send({
-      response: { request_id: request.id, ok: 'downstream_node_event' },
-    })
-    return
+    const request = msg.request as Record<string, unknown> | undefined
+    if (request?.edge_app_source_state) {
+      const state = request.edge_app_source_state as {
+        states: Array<{ name: string }>
+      }
+      state.states.forEach((s) => {
+        readings[s.name] = s
+      })
+      notifySubscribers()
+      send({
+        response: { request_id: request.id, ok: 'edge_app_source_state' },
+      })
+      continue
+    }
+
+    if (request?.downstream_node_event) {
+      send({
+        response: { request_id: request.id, ok: 'downstream_node_event' },
+      })
+      continue
+    }
   }
 }
Suggestion importance[1-10]: 5

__

Why: Splitting on ETB is a reasonable robustness improvement over replace(ETB, ''), which can break if multiple ETB-delimited JSON payloads arrive in a single message event. It’s not clearly required given WebSocket message boundaries, but it can prevent dropped updates if the integrator batches messages.

Low

Previous suggestions

Suggestions up to commit 157b403
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix readyState OPEN comparison

In ws, the readyState constants are not reliably available on the instance (ws.OPEN
may be undefined), which can prevent periodic updates from ever sending. Compare
readyState to the numeric OPEN value instead to make the mock server consistently
push snapshots.

edge-apps/edge-apps-library/vite-plugins/dev-server.ts [191-201]

 const interval = setInterval(() => {
-  if (!identified || ws.readyState !== ws.OPEN) return
+  if (!identified || ws.readyState !== 1) return
   const event =
     JSON.stringify({
       request: {
         id: ulid(),
         edge_app_source_state: { states: buildStateSnapshot() },
       },
     }) + ETB
   ws.send(event)
 }, 5000)
Suggestion importance[1-10]: 8

__

Why: In ws, OPEN is a static constant (typically WebSocket.OPEN), and ws.OPEN on the instance can be undefined, causing the interval push to never send. Comparing to the correct open state fixes a real functional bug in the mock peripheral snapshot broadcaster.

Medium
Close mock server and fix logs

The server logs "listening" unconditionally even when EADDRINUSE occurs, and it
isn't closed when Vite shuts down, which can leave the port occupied across
restarts. Return the WebSocketServer from startPeripheralMockServer(), log only on
the listening event, and close it on Vite's HTTP server close to avoid port leaks.

edge-apps/edge-apps-library/vite-plugins/dev-server.ts [114-319]

-function startPeripheralMockServer(): void {
+function startPeripheralMockServer(): WebSocketServer {
   const wss = new WebSocketServer({ port: PERIPHERAL_WS_PORT })
-...
+
+  wss.on('listening', () => {
+    console.log(
+      `[screenly-dev-server] Peripheral mock WS server listening on ws://127.0.0.1:${PERIPHERAL_WS_PORT}`,
+    )
+  })
+
   wss.on('error', (err: NodeJS.ErrnoException) => {
     if (err.code === 'EADDRINUSE') {
       console.warn(
         `[screenly-dev-server] Port ${PERIPHERAL_WS_PORT} already in use — peripheral mock not started.`,
       )
     } else {
       console.error('[screenly-dev-server] Peripheral WS error:', err.message)
     }
   })
 
-  console.log(
-    `[screenly-dev-server] Peripheral mock WS server listening on ws://127.0.0.1:${PERIPHERAL_WS_PORT}`,
-  )
+  // eslint-disable-next-line max-lines-per-function
+  wss.on('connection', (ws) => {
+    let identified = false
+    ...
+  })
+
+  return wss
 }
-...
-configureServer(server: ViteDevServer) {
-  rootDir = server.config.root
 
-  // Start peripheral mock WebSocket server
-  startPeripheralMockServer()
+export function screenlyDevServer(): Plugin {
+  let config: BaseScreenlyMockData
+  let rootDir: string
+  let peripheralWss: WebSocketServer | null = null
 
+  return {
+    name: 'screenly-dev-server',
+    configureServer(server: ViteDevServer) {
+      rootDir = server.config.root
+
+      // Start peripheral mock WebSocket server
+      if (!peripheralWss) {
+        peripheralWss = startPeripheralMockServer()
+        server.httpServer?.once('close', () => peripheralWss?.close())
+      }
+
+      // Generate initial mock data
+      config = generateMockData(rootDir)
+      ...
+    },
+  }
+}
+
Suggestion importance[1-10]: 7

__

Why: Logging “listening” unconditionally is misleading when EADDRINUSE happens, and not closing the WebSocketServer can leave the port stuck across dev-server restarts. Returning the server instance, logging on listening, and closing on Vite shutdown materially improves dev reliability.

Medium
Properly frame WebSocket messages

WebSocket messages can arrive split or concatenated, so stripping a single ETB and
parsing once can silently drop valid frames or fail on partial JSON. Buffer incoming
data and split on ETB, then parse each complete frame. Also guard against non-string
MessageEvent.data to avoid parsing "[object Blob]".

edge-apps/edge-apps-library/src/peripherals/index.ts [55-94]

+let recvBuffer = ''
+
 ws.onmessage = (e: MessageEvent) => {
-  const text = (e.data as string).replace(ETB, '')
-  let msg: Record<string, unknown>
-  try {
-    msg = JSON.parse(text) as Record<string, unknown>
-  } catch {
-    return
-  }
+  if (typeof e.data !== 'string') return
 
-  const response = msg.response as Record<string, unknown> | undefined
-  if (
-    response?.ok !== undefined &&
-    (response.ok as Record<string, unknown>)?.identification !== undefined
-  ) {
-    identified = true
-    return
-  }
+  recvBuffer += e.data
+  const frames = recvBuffer.split(ETB)
+  recvBuffer = frames.pop() ?? ''
 
-  const request = msg.request as Record<string, unknown> | undefined
-  if (request?.edge_app_source_state) {
-    const state = request.edge_app_source_state as {
-      states: Array<{ name: string }>
+  for (const frame of frames) {
+    if (!frame) continue
+
+    let msg: Record<string, unknown>
+    try {
+      msg = JSON.parse(frame) as Record<string, unknown>
+    } catch {
+      continue
     }
-    state.states.forEach((s) => {
-      readings[s.name] = s
-    })
-    notifySubscribers()
-    send({
-      response: { request_id: request.id, ok: 'edge_app_source_state' },
-    })
-    return
-  }
 
-  if (request?.downstream_node_event) {
-    send({
-      response: { request_id: request.id, ok: 'downstream_node_event' },
-    })
-    return
+    const response = msg.response as Record<string, unknown> | undefined
+    if (
+      response?.ok !== undefined &&
+      (response.ok as Record<string, unknown>)?.identification !== undefined
+    ) {
+      identified = true
+      continue
+    }
+
+    const request = msg.request as Record<string, unknown> | undefined
+    if (request?.edge_app_source_state) {
+      const state = request.edge_app_source_state as {
+        states: Array<{ name: string }>
+      }
+      state.states.forEach((s) => {
+        readings[s.name] = s
+      })
+      notifySubscribers()
+      send({
+        response: { request_id: request.id, ok: 'edge_app_source_state' },
+      })
+      continue
+    }
+
+    if (request?.downstream_node_event) {
+      send({
+        response: { request_id: request.id, ok: 'downstream_node_event' },
+      })
+    }
   }
 }
Suggestion importance[1-10]: 6

__

Why: The current ws.onmessage assumes exactly one JSON payload per WebSocket message and strips only a single ETB, which can break if multiple frames are ever delivered together (or if e.data is not a string). The buffering/splitting approach is a reasonable robustness improvement, though WebSocket message boundaries often already preserve frames.

Low
Suggestions up to commit 2b6f1a8
CategorySuggestion                                                                                                                                    Impact
Security
Bind to localhost and log correctly

Bind the mock WebSocket server explicitly to 127.0.0.1 (the current code likely
binds to all interfaces), and only log “listening” after the server is actually
listening. This avoids accidental LAN exposure and misleading logs when EADDRINUSE
happens.

edge-apps/edge-apps-library/vite-plugins/dev-server.ts [76-169]

 function startPeripheralMockServer(): void {
-  const wss = new WebSocketServer({ port: PERIPHERAL_WS_PORT })
+  const wss = new WebSocketServer({ port: PERIPHERAL_WS_PORT, host: '127.0.0.1' })
+
+  wss.once('listening', () => {
+    console.log(
+      `[screenly-dev-server] Peripheral mock WS server listening on ws://127.0.0.1:${PERIPHERAL_WS_PORT}`,
+    )
+  })
 
   wss.on('connection', (ws) => {
     let identified = false
     ...
   })
 
-  wss.on('error', (err: NodeJS.ErrnoException) => {
+  wss.once('error', (err: NodeJS.ErrnoException) => {
     if (err.code === 'EADDRINUSE') {
       console.warn(
         `[screenly-dev-server] Port ${PERIPHERAL_WS_PORT} already in use — peripheral mock not started.`,
       )
     } else {
       console.error('[screenly-dev-server] Peripheral WS error:', err.message)
     }
+    try {
+      wss.close()
+    } catch {
+      // ignore
+    }
   })
-
-  console.log(
-    `[screenly-dev-server] Peripheral mock WS server listening on ws://127.0.0.1:${PERIPHERAL_WS_PORT}`,
-  )
 }
Suggestion importance[1-10]: 8

__

Why: The mock WebSocketServer currently binds implicitly (potentially all interfaces) and logs “listening” unconditionally, which can be both a security footgun and misleading when EADDRINUSE occurs. Binding to 127.0.0.1 and logging on the actual listening event improves correctness and reduces accidental exposure.

Medium
Possible issue
Parse ETB-delimited WS frames

The WebSocket payload may contain multiple ETB-delimited frames (or a non-Buffer
type), and replace(ETB, '') only removes the first ETB, which can break JSON
parsing. Split on ETB, parse each frame, and validate requestId before responding to
avoid emitting malformed ACKs.

edge-apps/edge-apps-library/vite-plugins/dev-server.ts [82-129]

-ws.on('message', (raw: Buffer) => {
-  const text = raw.toString().replace(ETB, '')
-  let msg: Record<string, unknown>
-  try {
-    msg = JSON.parse(text)
-  } catch {
-    return
+ws.on('message', (raw) => {
+  const rawText = typeof raw === 'string' ? raw : Buffer.from(raw as any).toString()
+  const frames = rawText.split(ETB).map((s) => s.trim()).filter(Boolean)
+
+  for (const frame of frames) {
+    let msg: Record<string, unknown>
+    try {
+      msg = JSON.parse(frame)
+    } catch {
+      continue
+    }
+
+    const req = msg.request as Record<string, unknown> | undefined
+    if (!req) continue
+
+    const requestId = req.id
+    if (typeof requestId !== 'string' || requestId.length === 0) continue
+
+    // Identification handshake
+    if (req.identification) {
+      identified = true
+      ws.send(
+        JSON.stringify({
+          response: { request_id: requestId, ok: { identification: null } },
+        }) + ETB,
+      )
+      continue
+    }
+
+    if (!identified) continue
+
+    // GetState request
+    const channelName = req.source_channel_get_state as string | undefined
+    if (channelName && channelName in SENSOR_META) {
+      const sensor = channelName as SensorType
+      const meta = SENSOR_META[sensor]
+      const value = makeMockSensorValue(sensor)
+      ws.send(
+        JSON.stringify({
+          response: {
+            request_id: requestId,
+            ok: {
+              source_channel_get_state: {
+                name: channelName,
+                [meta.wireKey]: value,
+                unit: meta.unit,
+                timestamp: new Date().toISOString(),
+              },
+            },
+          },
+        }) + ETB,
+      )
+    }
   }
-
-  const req = msg.request as Record<string, unknown> | undefined
-  if (!req) return
-
-  const requestId = req.id as string
-
-  // Identification handshake
-  if (req.identification) {
-    identified = true
-    const ack = JSON.stringify({
-      response: { request_id: requestId, ok: { identification: null } },
-    }) + ETB
-    ws.send(ack)
-    return
-  }
-  ...
 })
Suggestion importance[1-10]: 6

__

Why: Using replace(ETB, '') only removes one delimiter and assumes one JSON payload per WS message; splitting by ETB and iterating frames makes the mock more robust to concatenated frames and avoids JSON parse failures. The added requestId validation also prevents emitting malformed ACKs if the payload is unexpected.

Low
Safely decode and split messages

In browsers, e.data can be a Blob/ArrayBuffer, and calling .replace() will throw;
additionally, multiple ETB-delimited frames can arrive in a single message. Convert
e.data to text safely, split by ETB, and process each frame to prevent runtime
crashes and dropped events.

edge-apps/edge-apps-library/vite-plugins/dev-server.ts [227-253]

-ws.onmessage = (e) => {
-  const text = e.data.replace(ETB, '')
-  let msg
-  try { msg = JSON.parse(text) } catch { return }
+ws.onmessage = async (e) => {
+  const rawText =
+    typeof e.data === 'string'
+      ? e.data
+      : e.data instanceof Blob
+        ? await e.data.text()
+        : new TextDecoder().decode(e.data)
 
-  // Handle identification ACK
-  if (msg.response?.ok?.identification !== undefined) {
-    identified = true
-    return
-  }
+  const frames = rawText.split(ETB).map((s) => s.trim()).filter(Boolean)
+  for (const frame of frames) {
+    let msg
+    try {
+      msg = JSON.parse(frame)
+    } catch {
+      continue
+    }
 
-  // Handle ACK requirement for unsolicited push events
-  if (msg.request?.source_channel_event) {
-    const event = normalizeEvent(msg.request.source_channel_event)
-    if (event) {
-      subscribers.forEach(cb => cb(event))
-      dispatchEvent(new CustomEvent('screenly:peripheral', { detail: event }))
+    // Handle identification ACK
+    if (msg.response?.ok?.identification !== undefined) {
+      identified = true
+      continue
     }
-    send({ response: { request_id: msg.request.id, ok: 'source_channel_event' } })
-    return
-  }
 
-  if (msg.request?.downstream_node_event) {
-    send({ response: { request_id: msg.request.id, ok: 'downstream_node_event' } })
-    return
+    // Handle ACK requirement for unsolicited push events
+    if (msg.request?.source_channel_event) {
+      const event = normalizeEvent(msg.request.source_channel_event)
+      if (event) {
+        subscribers.forEach((cb) => cb(event))
+        dispatchEvent(new CustomEvent('screenly:peripheral', { detail: event }))
+      }
+      send({ response: { request_id: msg.request.id, ok: 'source_channel_event' } })
+      continue
+    }
+
+    if (msg.request?.downstream_node_event) {
+      send({ response: { request_id: msg.request.id, ok: 'downstream_node_event' } })
+      continue
+    }
   }
 }
Suggestion importance[1-10]: 5

__

Why: In the generated browser-side screenly.js, calling .replace() on e.data assumes a string and can crash if e.data is a Blob/ArrayBuffer; safely decoding and splitting by ETB prevents runtime failures and missed events. This is a defensive improvement (the current mock likely sends text frames), so impact is moderate.

Low

@nicomiguelino nicomiguelino changed the title feat(edge-apps): add peripheral integration POC poc(peripheral-integration): add peripheral integration POC Mar 2, 2026
nicomiguelino and others added 4 commits March 2, 2026 13:46
- Reformat multi-property CSS transition to multi-line style
- Wrap long TypeScript lines for better readability
- Update screenshots for all supported resolutions
- Add eslint-disable comments for max-lines and max-lines-per-function
- Reformat long lines and type definitions to satisfy Prettier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace `PeripheralEvent` with `PeripheralReading` and `PeripheralSnapshot` types
- Aggregate all `source_channel_get_state` responses into a single snapshot before notifying subscribers
- Update snapshot on each `source_channel_event` push and re-notify with full state
- Add `_timestamp`, `_id`, and `_uptime` metadata fields to snapshot
- Update `main.ts` to consume `PeripheralSnapshot` in the subscribe callback
- Update `POC.md` TL;DR and types section to reflect new snapshot shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nicomiguelino nicomiguelino requested a review from 514sid March 3, 2026 03:42
nicomiguelino and others added 7 commits March 2, 2026 19:52
- Add spaces around dashes in table separator rows to comply with MD060

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `peripheral-integration-poc` Edge App showing live sensor data
- Add `startPeripheralMockServer()` to dev-server with Octo-Avenger protocol
- Expose `screenly.peripherals.watchState(cb)` with lazy WebSocket connection
- Add `PeripheralReading` and `ScreenlyPeripherals` types to edge-apps-library

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Port multi-screen app from embedded-world-demo-app reference
- Includes public clock, operator dashboard, and maintenance views
- Add role-based screen switching with welcome overlay and auto-logout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `peripherals.ts` that calls `watchState` and feeds ambient temperature into state
- Register `initPeripherals` in `main.ts`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add authenticate() with hardcoded operator/maintenance card IDs
- Handle secure_card_id readings in peripherals to trigger screen switch
- Export showWelcomeThenSwitch for use outside screen module
- Use fixed mock card IDs (DEADBEEF/CAFEBABE) instead of random hex
- Increase dev server push interval from 3s to 5s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch push format from source_channel_event to edge_app_source_state
- Send full state snapshot on identification and every 5 seconds
- Add humidity and air_pressure mock channels
- Add server-side generateUlid() and buildStateSnapshot() helpers
- Change PeripheralReading.timestamp from ISO string to epoch ms
- Simplify screenly.js client by removing fetchInitialSnapshot and normalizeEvent
- Update POC.md to reflect new schema and wire format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor Author

@nicomiguelino nicomiguelino left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget to remove edge-apps/peripheral-integration-poc/ afterwards. We don't need it anymore.

- Remove peripheral-integration-poc as it has been superseded by peripheral-integration-demo
- Move Peripheral API docs to peripheral-integration-demo README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nicomiguelino nicomiguelino changed the title poc(peripheral-integration): add peripheral integration POC feat(peripheral-demo): add Edge App for peripheral integration Mar 4, 2026
nicomiguelino and others added 3 commits March 3, 2026 19:01
…ttings

- Update Peripheral API code snippet to reflect actual usage in the app
- Remove stale `message` setting from configuration table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `PeripheralStateMessage` type matching the Octo-Avenger wire shape
- Update `watchState` signature to pass `PeripheralStateMessage` instead of `PeripheralReading[]`
- Update `notifySubscribers` in dev-server to wrap readings in the wire envelope
- Update `peripherals.ts` and README to reflect the new callback shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ation

- Add `register(edgeAppId)` to `ScreenlyPeripherals` and IIFE — sends registration after identification ACK
- Add `screenly_edge_app_id` to `ScreenlyObject` type
- Call `register()` from `peripherals.ts` before `watchState`, falling back to a generated ULID
- Fix `generateUlid()` to produce correct 26-char ULIDs using Crockford's Base32 alphabet
- Mock server silently accepts `edge_app_registration` (fire-and-forget)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…positives

- Replace numeric `assetsInlineLimit` with a function
- Font files (woff2, ttf, otf, eot) are never inlined as base64
- All other assets (e.g. images) still inline if under 7 MB
- Add Authentication and Dashboards categories to screenly.yml
- Add Authentication and Dashboards categories to screenly_qc.yml
514sid and others added 4 commits March 5, 2026 13:40
- Change top bar title to lowercase
- Adjust spacing in public screen layout
- Modify padding for public clock section
- Reorganize secure access and safety carousel cards
…ient

- Remove `identified` flag — PI no longer sends identification ACK
- Remove identification response handler from `onmessage`
- Send registration immediately on `open` instead of waiting for ACK
…tion

- Remove identification handshake from client — init is now connect + register only
- Remove identification handler and `identified` flag from dev server mock
- Push initial state snapshot on connection instead of after identification
- Drop `identified` guard from periodic state push interval
- Rename `PeripheralState` to `SourceChannelStateReport`
- Rename `PeripheralStateMessage` to `EdgeAppSourceState`
- Update all references in peripherals client and demo app
nicomiguelino and others added 15 commits March 5, 2026 11:03
… clarity

- Extract `send` into a top-level `sendMessage` that throws on failure
- Add JSDoc to `sendMessage` and `notifySubscribers`
- Add inline comment explaining the `notifySubscribers` call site
- Log a warning instead of silently dropping malformed JSON frames
- Remove `[screenly]` prefix from WebSocket error log message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Edge Apps never receive raw `downstream_node_event` messages
- The hardware integration service handles the ACK back to the driver
  internally and transforms the event into `source_channel_loss` or
  `source_channel_resume` before forwarding to Edge Apps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename `SourceChannelStateReport` back to `PeripheralState`
- Rename `EdgeAppSourceState` back to `PeripheralStateMessage`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove @import 'tailwindcss' to fix rendering on QtWebEngine (Pi 4)
- Replace Tailwind utility classes in index.html with semantic CSS classes
- Add layout classes: main-content, screen, card-stack, card-header, operator-grid, progress-row
- Add JS-toggled utilities: .hidden and .opacity-0
- Inline padding-block on .public-clock, transition on #safety-text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ate card mappings

- Add Blob message handling in peripheral client to support binary WebSocket data
- Update card-to-role mapping with actual NFC card UIDs (uhtzBg, gj-6XA)
- Remove obsolete code comments

Made-with: Cursor
…temperature updates

- Track previous screen state to only restart timer on screen changes
- Prevent continuous temperature updates from resetting the countdown
- Fixes timer jumping from 59 to 51 and back on Raspberry Pi

Made-with: Cursor
… to extend session

- Export restartLogoutTimer from timer module
- Call restartLogoutTimer when valid card scanned on same screen
- Allows users to extend session by re-scanning their card

Made-with: Cursor
…ate.ts

- Update MOCK_CARD_UIDS to match the card mappings in authenticate.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…operator and maintenance

- Add isTransitioning flag to block screen visibility updates during role switch
- Keep all screens hidden while welcome overlay and preload are in progress
- Prevents temperature updates from briefly showing previous role's UI on Pi

Made-with: Cursor
…maintenance view

- Map Hardware enum to display-friendly format (e.g. Raspberry Pi, Screenly Player Max)
- Fixes hardware name showing as RaspberryPi without space

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants