APIs & Devices NotesRohit’s interview study guide
Chapter 11

Testing and Delivering Device Software

How software for a fleet of devices gets proved and shipped: API tests at three levels, simulators and emulators, real hardware in a lab, a pipeline that signs what it builds, staged rollouts that stop themselves, and the flags, telemetry and diagnostics you need once it's in the field.

5 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Testing APIs: unit, integration and contract

Added

A device API has three kinds of bug, and each needs a different test to find it. Logic bugs: the volume check lets 140 through. Wiring bugs: the logic is right, but the HTTP layer around it isn't: the body parser ignores a content type, a route is registered twice, the error handler returns HTML instead of problem details. Promise bugs: the device works and its own tests pass, but it no longer does what the web UI team, the SDK team and partners were told it does, because a field was renamed or a new status code appeared. Unit tests find the first, integration tests the second, contract tests the third.

The mental model is inspecting a house. Unit tests check each part on the bench: this switch clicks, this pipe holds pressure. Integration tests turn on the mains in the finished house and walk through it: does flipping the switch in the hall light the hall? A contract test takes the plans the buyer signed and checks the house against them: three bedrooms were promised, and there are three, with the doors where the drawing says. A house can pass the first two and fail the third, if the builder moved a wall because it seemed better. That is exactly what happens when a developer renames volume to volumeLevel and updates their own tests to match.

On a device platform the third kind matters more than usual, because the API has many consumers you don't control: the on-device admin web UI, a cloud management service, an SDK, partner integrations (room booking, control systems), and scripts customers wrote years ago against firmware that's still in the field. The spec (OpenAPI, see Contract testing) is the one statement all of them rely on, so it's the thing tests should hold the device to.

LevelWhat's realWhat's fakedSpeedFinds
UnitOne function or moduleEverything around itMicroseconds per testWrong logic, edge cases in validation, merge rules, state transitions
Integration (in-process)The real HTTP app: routing, middleware, parsers, error handlingThe hardware and OS services behind it (fakes or a simulator)Milliseconds per testWiring: content types, status codes, headers, auth middleware order
ContractThe real responses, compared with the specNothing about the device; the spec is the oracleMilliseconds per testDrift: renamed fields, undocumented statuses, wrong media types
End-to-end on a simulator or emulatorUI + API + a stand-in deviceThe hardwareSeconds per testFlows: reboot, update, reconnect (see Device simulators and emulators)
Hardware-in-the-loopThe real deviceOnly the room around itMinutes per testTiming, power loss, peripherals, performance (see Hardware-in-the-loop and lab testing)
Hardware in the loop real device, lab, nightly End to end on a simulator UI + API + fake device, per merge Contract tests every response checked against openapi.yaml Integration tests the real Express app over HTTP (supertest) Unit tests validation, merge rules, state machines more real slower, fewer faster, cheaper more of them
Many fast tests at the bottom, a few expensive ones at the top; each level finds bugs the one below can't.

The example below tests one endpoint, PATCH /api/v1/settings, at the first two levels. The validation logic is unit-tested by calling the functions directly; the same app is then integration-tested over real HTTP with supertest, which takes an Express app, binds it to an ephemeral port on 127.0.0.1 for the request, and closes it afterwards (supertest 7.3's README and lib/test.js, read October 2026). The app has a deliberate wiring bug, the kind that's common in device code: the client sends application/merge-patch+json (RFC 7396, the right media type for a partial update, see Partial updates), but express.json() only parses application/json unless told otherwise.

api-layers.mjs
// Unit tests vs integration tests for one device endpoint: PATCH /api/v1/settings.
import express from "express";
import request from "supertest";

// ---- The code under test ----
const RULES = {
  name: { type: "string", maxLength: 32 },
  volume: { type: "integer", min: 0, max: 100 },
  brightness: { type: "integer", min: 0, max: 100 },
};
function validatePatch(patch) {
  const errors = [];
  for (const [key, value] of Object.entries(patch ?? {})) {
    const rule = RULES[key];
    if (!rule) errors.push(`unknown setting '${key}'`);
    else if (rule.type === "integer" && !Number.isInteger(value)) errors.push(`${key} must be an integer`);
    else if (rule.type === "integer" && (value < rule.min || value > rule.max)) errors.push(`${key} must be ${rule.min}–${rule.max}`);
    else if (rule.type === "string" && (typeof value !== "string" || value.length > rule.maxLength)) errors.push(`${key} must be a string of at most ${rule.maxLength}`);
  }
  return errors;
}
const applyPatch = (current, patch) => ({ ...current, ...patch });

function createApp({ fixed }) {
  const store = { settings: { name: "Huddle 4", volume: 50, brightness: 80 } };
  const app = express();
  // The bug: express.json() only parses Content-Type application/json by default.
  app.use(express.json(fixed ? { type: ["application/json", "application/merge-patch+json"] } : {}));
  app.get("/api/v1/settings", (req, res) => res.json(store.settings));
  app.patch("/api/v1/settings", (req, res) => {
    const errors = validatePatch(req.body);
    if (errors.length) return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors });
    store.settings = applyPatch(store.settings, req.body);
    res.json(store.settings);
  });
  return app;
}

// ---- A tiny test runner (prints the same thing every run, unlike timing reporters) ----
async function run(title, tests) {
  console.log(title);
  let failed = 0;
  for (const [name, fn] of tests) {
    try { await fn(); console.log(`  ✓ ${name}`); }
    catch (err) { failed++; console.log(`  ✗ ${name}: ${err.message}`); }
  }
  console.log(`  ${tests.length - failed} passed, ${failed} failed`);
}
const expect = (actual, expected, what) => {
  if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
};

const unit = [
  ["accepts a valid volume", () => expect(validatePatch({ volume: 40 }), [], "errors")],
  ["rejects volume 140", () => expect(validatePatch({ volume: 140 }), ["volume must be 0–100"], "errors")],
  ["rejects a misspelt key", () => expect(validatePatch({ volum: 40 }), ["unknown setting 'volum'"], "errors")],
  ["merges a patch", () => expect(applyPatch({ volume: 50, brightness: 80 }, { volume: 40 }), { volume: 40, brightness: 80 }, "settings")],
];

const integration = (app) => [
  ["GET returns JSON settings", async () => {
    const res = await request(app).get("/api/v1/settings");
    expect([res.status, res.type, res.body.volume], [200, "application/json", 50], "status, type, volume");
  }],
  ["PATCH volume 140 → 422 problem details", async () => {
    const res = await request(app).patch("/api/v1/settings").send({ volume: 140 });
    expect([res.status, res.type, res.body.errors], [422, "application/problem+json", ["volume must be 0–100"]], "status, type, errors");
  }],
  ["PATCH as merge-patch+json changes the volume", async () => {
    const res = await request(app).patch("/api/v1/settings").set("Content-Type", "application/merge-patch+json").send(JSON.stringify({ volume: 40 }));
    const after = await request(app).get("/api/v1/settings");
    expect([res.status, after.body.volume], [200, 40], "status, volume after");
  }],
];

await run("Unit tests (functions called directly)", unit);
await run("Integration tests (real Express app over HTTP, buggy)", integration(createApp({ fixed: false })));
await run("Integration tests (real Express app over HTTP, fixed)", integration(createApp({ fixed: true })));
Result
Unit tests (functions called directly)
  ✓ accepts a valid volume
  ✓ rejects volume 140
  ✓ rejects a misspelt key
  ✓ merges a patch
  4 passed, 0 failed
Integration tests (real Express app over HTTP, buggy)
  ✓ GET returns JSON settings
  ✓ PATCH volume 140 → 422 problem details
  ✗ PATCH as merge-patch+json changes the volume: status, volume after: expected [200,40], got [200,50]
  2 passed, 1 failed
Integration tests (real Express app over HTTP, fixed)
  ✓ GET returns JSON settings
  ✓ PATCH volume 140 → 422 problem details
  ✓ PATCH as merge-patch+json changes the volume
  3 passed, 0 failed
What’s happening
  1. The unit tests call validatePatch and applyPatch directly, with plain objects. { volume: 140 } produces ["volume must be 0–100"], the misspelt volum is caught as an unknown key, and the merge keeps brightness: 80 while changing volume from 50 to 40. All four pass: the logic is correct.
  2. The integration tests hand the whole Express app to supertest. request(app).get(...) starts a real HTTP server on an ephemeral port, sends a real request through Express's router and middleware, and gives back status, headers and parsed body. The first test checks all three at once: 200, application/json, volume 50.
  3. The second test sends { volume: 140 }. Supertest's .send(object) sets Content-Type: application/json, which express.json() parses, so validation runs and the response is 422 with application/problem+json — the wiring for errors is right.
  4. The third test sends the body as application/merge-patch+json, which is what the web UI and the SDK actually send. With the default express.json(), the content type doesn't match, so the parser skips the body and req.body stays undefined. The reason is in body-parser (2.4.0, the version Express 5.3 installs here): json() defaults its type option to "application/json" and checks it with type-is, which matches that exact media type, not every +json type; for a non-matching request it calls next() without reading the body, after setting req.body = undefined (in body-parser 1.x it was {}). type: "application/*+json" would also have fixed it. validatePatch(undefined) iterates patch ?? {}, finds nothing wrong, and the handler returns 200 with the unchanged settings. The follow-up GET shows volume still 50.
  5. That is the worst kind of device bug: a success response for a change that never happened. The unit tests can't see it, because the functions are fine; only a test that goes through the real HTTP stack with the real content type finds it. The fix is one line, express.json({ type: ["application/json", "application/merge-patch+json"] }), and the third run passes.
  6. If you changed validatePatch to reject a missing body (if (patch === undefined) return ["request body is required"]), the buggy app would answer 422 instead of 200: still broken, but loudly. Better still, give each failure its own status, as in Methods, status codes and idempotency for device APIs: a content type the endpoint doesn't parse gets 415 Unsupported Media Type, a body that isn't valid JSON gets 400 Bad Request (that's what express.json() itself raises for a parse error), and well-formed JSON with a bad value, like volume 140, gets 422 with problem details.
AdvancedContract tests: hold the device to the spec

Unit and integration tests are written by the people who wrote the code, so they test what the code does, and when the code changes, the tests are changed to match. A contract test uses a different oracle: the OpenAPI document that consumers were given. For every response the device produces in a test, it looks up the operation in the spec, checks that the status code is documented, that the content type is documented for that status, and that the body validates against the schema. Pact's documentation (docs.pact.io, checked October 2026) defines contract testing as "a technique for testing an integration point by checking each application in isolation" against a shared contract; checking a provider's responses against its OpenAPI document is the provider-side half of that, and the Pact docs point out its limit: it doesn't prove that consumers call the API correctly.

The model below uses the packages installed for this site: yaml to parse the spec, @apidevtools/swagger-parser 13.1 to validate the OpenAPI 3.1 document and resolve every $ref, and Ajv 8.20's 2020-12 build (OpenAPI 3.1 schemas are JSON Schema 2020-12) to validate bodies. It runs the same four requests against two builds: build A as released, and build B, where a developer renamed volume to volumeLevel "for clarity" and added a 409 for duplicate room names.

contract-test.mjs
// A spec-driven contract test: every response the device API gives must be one the OpenAPI spec promises.
import express from "express";
import request from "supertest";
import YAML from "yaml";
import SwaggerParser from "@apidevtools/swagger-parser";
import Ajv2020 from "ajv/dist/2020.js";

const specYaml = `
openapi: 3.1.0
info: { title: Room device API, version: 1.4.0 }
paths:
  /api/v1/settings:
    get:
      responses:
        "200": { description: Current settings, content: { application/json: { schema: { $ref: "#/components/schemas/Settings" } } } }
    patch:
      requestBody: { content: { application/merge-patch+json: { schema: { type: object } } } }
      responses:
        "200": { description: Updated settings, content: { application/json: { schema: { $ref: "#/components/schemas/Settings" } } } }
        "422": { description: Invalid settings, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
components:
  schemas:
    Settings:
      type: object
      required: [name, volume, brightness]
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 32 }
        volume: { type: integer, minimum: 0, maximum: 100 }
        brightness: { type: integer, minimum: 0, maximum: 100 }
    Problem:
      type: object
      required: [title, status]
      properties: { title: { type: string }, status: { type: integer }, errors: { type: array, items: { type: string } } }
`;
// validate() checks the document against the OpenAPI 3.1 schema and resolves every $ref.
const spec = await SwaggerParser.validate(YAML.parse(specYaml));
const ajv = new Ajv2020({ allErrors: true });

function checkAgainstSpec(method, path, res) {
  const op = spec.paths[path]?.[method];
  if (!op) return [`${method.toUpperCase()} ${path} is not in the spec`];
  const documented = op.responses[String(res.status)];
  if (!documented) return [`status ${res.status} is not documented (documented: ${Object.keys(op.responses).join(", ")})`];
  const media = documented.content?.[res.type];
  if (!media) return [`content type ${res.type} is not documented for ${res.status}`];
  const validate = ajv.compile(media.schema);
  return validate(res.body) ? [] : validate.errors.map((e) => `body${e.instancePath} ${e.message}${e.params.additionalProperty ? ` '${e.params.additionalProperty}'` : ""}`);
}

// Two builds of the device's API. In build B a developer renamed volume → volumeLevel and added a 409.
function createApp(build) {
  let settings = { name: "Huddle 4", volume: 50, brightness: 80 };
  const out = (s) => (build === "B" ? { name: s.name, volumeLevel: s.volume, brightness: s.brightness } : s);
  const app = express();
  app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));
  app.get("/api/v1/settings", (req, res) => res.json(out(settings)));
  app.patch("/api/v1/settings", (req, res) => {
    if (build === "B" && req.body.name === "") return res.status(409).json({ error: "name in use" });
    if (req.body.volume > 100) return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors: ["volume must be 0–100"] });
    settings = { ...settings, ...req.body };
    res.json(out(settings));
  });
  return app;
}

// The cases: one request per documented response, plus an edge case.
const cases = [
  ["get", "/api/v1/settings", null],
  ["patch", "/api/v1/settings", { volume: 40 }],
  ["patch", "/api/v1/settings", { volume: 140 }],
  ["patch", "/api/v1/settings", { name: "" }],
];
for (const build of ["A", "B"]) {
  console.log(`Build ${build}`);
  const app = createApp(build);
  for (const [method, path, body] of cases) {
    let req = request(app)[method](path);
    if (body) req = req.set("Content-Type", "application/merge-patch+json").send(JSON.stringify(body));
    const res = await req;
    const problems = checkAgainstSpec(method, path, res);
    const label = `${method.toUpperCase()} ${path}${body ? " " + JSON.stringify(body) : ""} → ${res.status}`;
    console.log(problems.length ? `  ✗ ${label}\n      ${problems.join("\n      ")}` : `  ✓ ${label}`);
  }
}
Result
Build A
  ✓ GET /api/v1/settings → 200
  ✓ PATCH /api/v1/settings {"volume":40} → 200
  ✓ PATCH /api/v1/settings {"volume":140} → 422
  ✓ PATCH /api/v1/settings {"name":""} → 200
Build B
  ✗ GET /api/v1/settings → 200
      body must have required property 'volume'
      body must NOT have additional properties 'volumeLevel'
  ✗ PATCH /api/v1/settings {"volume":40} → 200
      body must have required property 'volume'
      body must NOT have additional properties 'volumeLevel'
  ✓ PATCH /api/v1/settings {"volume":140} → 422
  ✗ PATCH /api/v1/settings {"name":""} → 409
      status 409 is not documented (documented: 200, 422)
What’s happening
  1. SwaggerParser.validate first checks that the spec itself is a valid OpenAPI 3.1 document (a broken spec would make every later check meaningless), then replaces each $ref with the schema it points to. After that, spec.paths["/api/v1/settings"].get.responses["200"].content["application/json"].schema is the full Settings schema object.
  2. checkAgainstSpec asks three questions of every response, in order: is this operation in the spec, is this status documented, is this content type documented for this status. Only then does it compile the schema with Ajv and validate the body. Ajv is created with allErrors: true so one run reports every problem, not just the first.
  3. Build A passes all four. Note the last case: { name: "" } is accepted with 200, because the spec has no minLength for name. The contract test doesn't judge whether that's wise; it only checks that the device does what the spec says. If an empty name should be rejected, the spec changes first, then the code.
  4. Build B's GET returns { name, volumeLevel, brightness }. Ajv reports both halves of the rename: the required volume is missing, and additionalProperties: false rejects the unknown volumeLevel. Every consumer that reads settings.volume (the web UI's slider, the SDK's getVolume(), a partner's room-control script) would now read undefined. The developer's own integration tests, updated in the same commit, would all be green.
  5. The 409 is a different kind of drift: the body might even be fine, but clients were never told this status exists, so their error handling can't be ready for it. Adding a status code to a response list is a spec change that has to be reviewed like any other.
  6. What this demonstrates: the spec is an independent oracle. Run in CI next to a spec diff (oasdiff, below), it turns "we accidentally broke partners" into a failed pull request.
AdvancedNegative tests generated from the schema

The spec doesn't only say what the device returns; it says what the device must refuse. A schema with maximum: 100 implies a test with 101; type: integer implies 0.5 and "100"; additionalProperties: false implies a misspelt key. Tools do this at scale: Schemathesis's documentation (checked October 2026) says it "automatically generates property-based tests from your OpenAPI or GraphQL schema". The idea fits in a few lines: walk the schema, produce the values just outside each rule, send each one, and expect 422.

schema-negatives.mjs
// Negative tests generated from the spec's schema: every value the schema forbids must get a 422.
import express from "express";
import request from "supertest";

const Settings = {
  type: "object", additionalProperties: false,
  properties: {
    name: { type: "string", maxLength: 32 },
    volume: { type: "integer", minimum: 0, maximum: 100 },
    brightness: { type: "integer", minimum: 0, maximum: 100 },
  },
};

// For each property, the boundary values just outside what the schema allows, plus wrong types.
function invalidPatches(schema) {
  const out = [];
  for (const [key, s] of Object.entries(schema.properties)) {
    if (s.type === "integer") {
      out.push([key, s.minimum - 1], [key, s.maximum + 1], [key, s.minimum + 0.5], [key, String(s.maximum)], [key, null]);
    }
    if (s.type === "string") out.push([key, "x".repeat(s.maxLength + 1)], [key, 42], [key, null]);
  }
  if (schema.additionalProperties === false) out.push(["volumee", 40]); // a key the schema doesn't define
  return out.map(([key, value]) => ({ [key]: value }));
}

// The device's handler, with the kind of hand-written validation that drifts from the spec.
const settings = { name: "Huddle 4", volume: 50, brightness: 80 };
const app = express();
app.use(express.json({ type: "application/merge-patch+json" }));
app.patch("/api/v1/settings", (req, res) => {
  for (const [key, v] of Object.entries(req.body)) {
    const bad =
      key === "name" ? typeof v !== "string" // forgot maxLength
      : key === "volume" || key === "brightness" ? typeof v !== "number" || v < 0 || v > 100 // forgot "integer"
      : true;
    if (bad) return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors: [`bad ${key}`] });
  }
  Object.assign(settings, req.body);
  res.json(settings);
});

const patches = invalidPatches(Settings);
const slipped = [];
for (const patch of patches) {
  const res = await request(app).patch("/api/v1/settings").set("Content-Type", "application/merge-patch+json").send(JSON.stringify(patch));
  const shown = JSON.stringify(patch).replace(/x{33}/, "x×33");
  if (res.status !== 422) slipped.push(`${shown} → ${res.status}`);
}
console.log(`${patches.length} invalid patches generated from the schema`);
console.log(`${patches.length - slipped.length} rejected with 422, ${slipped.length} accepted by mistake:`);
for (const s of slipped) console.log(`  ✗ ${s}`);
console.log(`stored settings now: ${JSON.stringify(settings).replace(/x{33}/, "x×33")}`);
Result
14 invalid patches generated from the schema
11 rejected with 422, 3 accepted by mistake:
  ✗ {"name":"x×33"} → 200
  ✗ {"volume":0.5} → 200
  ✗ {"brightness":0.5} → 200
stored settings now: {"name":"x×33","volume":0.5,"brightness":0.5}
What’s happening
  1. invalidPatches reads the schema, not the code. For each integer it makes five values: -1 (below minimum), 101 (above maximum), 0.5 (a number but not an integer), "100" (a string), and null (in a merge patch, null means "delete this member", which a required setting must refuse). For the string it makes a 33-character name, the number 42, and null. The extra key volumee tests additionalProperties: false. That's 5 + 5 + 3 + 1 = 14 patches.
  2. The handler's hand-written checks are typical: typeof v !== "number" || v < 0 || v > 100 looks complete, and typeof v !== "string" looks fine for a name. They catch 11 of the 14.
  3. Three slip through. A 33-character name passes because nobody wrote the maxLength check, and 0.5 passes for both volume and brightness because "is a number" isn't "is an integer". The stored settings end up as volume: 0.5 and a name that the device's on-screen label (sized for 32 characters) will truncate or overflow.
  4. Why this matters on devices: invalid values that get stored come back on every boot. A fractional volume that the audio HAL rejects can turn into a crash loop on startup, which is far worse than a 422.
  5. The fix isn't more hand-written ifs; it's validating requests with the same schema the spec publishes (Ajv compiled from openapi.yaml, see Validating requests and responses against the spec). Then the generated tests mostly prove the wiring, and the spec and the code can't drift apart.
AdvancedBreaking-change detection on the spec itself

Contract tests check the device against the spec. The other half is checking each change to the spec against the previous version: removing a field, adding a required request property, narrowing an enum, or adding a response status are breaking for some client. oasdiff compares two OpenAPI documents and classifies each change as an error (breaking), a warning or info; its GitHub Action (oasdiff/oasdiff-action/breaking, v0.1.20 released 21 October 2026) fails a pull request when it finds changes at or above the fail-on level. By default the action uploads both specs, encrypted, to oasdiff.com to build a side-by-side review page; for a private device API set review: false so no spec leaves CI (its action.yml documents both). The workflow in CI/CD for web UI, APIs and firmware shows it in place.

For fleets on old firmware, the base to compare against isn't only main: it's every API version still supported in the field. Versioning and how to test it across releases is the subject of Testing compatibility across versions.

AdvancedConsumer-driven contracts (Pact)

Provider-side contract tests answer "does the device match the spec?" They can't answer "does the web UI only use what the spec offers?" or "which fields does anyone actually use?" Consumer-driven contracts reverse the direction: each consumer's own tests record the requests it makes and the parts of each response it reads, as a contract file; the provider's CI replays those requests against the real API. Pact (docs.pact.io, checked October 2026) describes itself as "a code-first consumer-driven contract testing tool", and points out the main benefit: because contracts come from consumer tests, only the parts of the API that consumers actually use are tested, so the provider can change everything else freely.

Where they fit on a device platform:

  • Teams you can coordinate with (the web UI, the cloud service, the SDK) can publish Pact contracts. The device team sees exactly which fields each consumer depends on before removing anything.
  • Partners and customer scripts won't publish contracts, so the published OpenAPI spec, with breaking-change detection, stays the contract for them.
  • The two complement each other: the spec says what's promised, consumer contracts say what's relied on. A field that's in the spec but in no consumer contract is a candidate for deprecation, never for silent removal (partners may use it).
Which test catches it?

For each bug, name the cheapest test level that reliably catches it: (a) PATCH with brightness: -5 is accepted; (b) the 404 handler returns an HTML page instead of problem details; (c) a developer changes uptimeS from an integer to a string "600" and updates the unit and integration tests; (d) the web UI shows "Saved" but the device's display didn't change brightness until reboot.

Show answer

(a) Unit, (b) integration, (c) contract, (d) end-to-end on a simulator, and finally hardware in the loop.

What’s happening
  1. (a) is pure logic in the validator: validatePatch({ brightness: -5 }) should return an error. A unit test with boundary values (-1, 0, 100, 101) catches it in microseconds, and the schema-generated negative tests catch it too.
  2. (b) is wiring: the error-handling middleware, not any one function. Only a request through the real app (supertest asking for an unknown path and asserting res.type === "application/problem+json") shows what the 404 handler really sends.
  3. (c) is drift that the developer's own tests were updated to accept. Only an oracle the developer didn't edit, the published spec where uptimeS is type: integer, catches it, and breaking-change detection flags it again if they try to edit the spec too.
  4. (d) is behaviour across the UI, the API and the device: the API stored the value but the setting needs to be applied to the display service. A simulator that models "applied vs pending" catches the logic in CI; whether the real display driver applies it immediately can only be proved on real hardware.

#Device simulators and emulators

Added

A device team never has enough devices. Developers in two or three time zones, CI runners in the cloud, a cloud-management team that needs five hundred devices to test fleet views, a web UI developer who just needs something that answers GET /api/v1/status: none of them can have a rack of hardware each, and even if they could, real hardware is slow to reboot, hard to reset, and won't fail on demand. A device simulator is a piece of software that speaks the device's API and behaves like the device: the same routes, status codes and headers, the same state machine (ready, in a call, rebooting, updating), and controls that make it misbehave when a test asks.

The mental model is a flight simulator. The controls in the cockpit are identical to the real aircraft's, so what pilots learn transfers. The world outside is fake, which is the point: the instructor can fail an engine at 200 feet, which you would never do in a real plane, and can do it the same way every time. A good device simulator gives tests the same two properties: the interface is real (generated from or checked against the spec) and the bad days are scriptable (reboot, power loss, a slow network, an update that fails at 60%).

There's a spectrum of stand-ins, and each one buys fidelity at a price:

Stand-inWhat it isGood forMisses
Hand-written mockCanned responses in the test (MSW, nock, fixtures)One component's unit and UI testsState, timing, anything the author didn't think of
Recorded replayResponses captured from a real device, played backRealistic payloads, regression fixturesAnything that depends on earlier requests; goes stale with firmware
Behavioural simulatorA small server with the device's state machine, a fake clock and fault controlsUI and API end-to-end tests in CI, cloud fleet tests (hundreds of instances), demosReal OS, real hardware, real performance
EmulatorThe real OS image running in a virtual machine (Android Emulator, Cuttlefish)Platform and app code: permissions, services, Android behaviour changesVendor hardware: camera, codecs, HDMI, audio DSP, the OEM's own HALs
Real deviceThe productEverythingScale, speed, cost, reproducibility of faults

The model below is a behavioural simulator for the reboot flow, used by two kinds of test. An API test drives it with supertest. A UI test runs the admin UI's connection-banner logic (the code that decides what the banner says while the device is away) against the same simulator over real HTTP with fetch. The simulator's clock is fake, so a 45-second reboot takes no real time, and "unreachable" is modelled honestly: while it's down, the simulator destroys the TCP socket instead of answering, which is what a client sees when a device is rebooting.

device-sim.mjs
// One simulated device, used by an API test (supertest) and a UI-logic test (real fetch over HTTP).
import express from "express";
import request from "supertest";
import { createServer } from "node:http";

function createFakeClock() {
  let now = 0;
  const timers = [];
  return {
    now: () => now,
    setTimeout: (fn, ms) => timers.push({ at: now + ms, fn }),
    advance(ms) {
      now += ms;
      for (const t of timers.filter((t) => t.at <= now).sort((a, b) => a.at - b.at)) { timers.splice(timers.indexOf(t), 1); t.fn(); }
    },
  };
}

// The simulator: same routes and status codes as the real device, a state machine, a fake clock, fault controls.
function createSimulatedDevice({ clock, bootMs = 45_000, firmware = "5.0.1" }) {
  let state = "ready", bootedAt = 0, nextOp = 1;
  const app = express();
  app.use((req, res, next) => (state === "down" ? req.socket.destroy() : next())); // unreachable while rebooting
  app.get("/api/v1/status", (req, res) => res.json({ state, firmware, uptimeS: (clock.now() - bootedAt) / 1000 }));
  app.post("/api/v1/reboot", (req, res) => {
    res.status(202).location(`/api/v1/operations/${nextOp++}`).json({ status: "accepted" });
    res.on("finish", () => sim.powerCycle()); // reply first, then go down, like a real device
  });
  const sim = {
    app,
    powerCycle() {
      state = "down";
      clock.setTimeout(() => { state = "ready"; bootedAt = clock.now(); }, bootMs);
    },
  };
  return sim;
}

const t = (clock) => `t=${String(clock.now() / 1000).padStart(2)}s`;

// ---- API test: reboot returns 202, the device drops off, then comes back with uptime reset ----
{
  const clock = createFakeClock();
  const device = createSimulatedDevice({ clock });
  const status = () => request(device.app).get("/api/v1/status").then((r) => `${r.status} ${JSON.stringify(r.body)}`, (e) => `error ${e.code}`);
  clock.advance(600_000);
  console.log(`API test  ${t(clock)}   GET status → ${await status()}`);
  const r = await request(device.app).post("/api/v1/reboot");
  console.log(`API test  ${t(clock)}   POST reboot → ${r.status}, Location ${r.headers.location}`);
  clock.advance(44_000);
  console.log(`API test  ${t(clock)}   GET status → ${await status()}`);
  clock.advance(1_000);
  console.log(`API test  ${t(clock)}   GET status → ${await status()}`);
}

// ---- UI test: the admin UI's connection banner, polling every 5 s, against the same simulator ----
function createConnectionBanner({ baseUrl }) {
  let failures = 0, rebootRequested = false, banner = "Online";
  return {
    get banner() { return banner; },
    async requestReboot() { await fetch(`${baseUrl}/api/v1/reboot`, { method: "POST" }); rebootRequested = true; },
    async poll() {
      try {
        const s = await (await fetch(`${baseUrl}/api/v1/status`)).json();
        banner = failures ? `Back online (firmware ${s.firmware})` : "Online";
        failures = 0; rebootRequested = false;
      } catch {
        failures++;
        banner = rebootRequested ? `Restarting… reconnecting (attempt ${failures})` : failures >= 2 ? "Device unreachable" : "Online";
      }
    },
  };
}
for (const scenario of ["user reboot", "power loss"]) {
  const clock = createFakeClock();
  const device = createSimulatedDevice({ clock, bootMs: 20_000 });
  const server = createServer(device.app);
  await new Promise((r) => server.listen(0, "127.0.0.1", r));
  const ui = createConnectionBanner({ baseUrl: `http://127.0.0.1:${server.address().port}` });
  if (scenario === "user reboot") await ui.requestReboot(); else device.powerCycle();
  const seen = [];
  for (let i = 0; i < 6; i++) {
    clock.advance(5_000);
    await ui.poll();
    seen.push(`${t(clock)} ${ui.banner}`);
  }
  console.log(`UI test (${scenario}):\n  ${seen.join("\n  ")}`);
  server.closeAllConnections();
  await new Promise((r) => server.close(r));
}
Result
API test  t=600s   GET status → 200 {"state":"ready","firmware":"5.0.1","uptimeS":600}
API test  t=600s   POST reboot → 202, Location /api/v1/operations/1
API test  t=644s   GET status → error ECONNRESET
API test  t=645s   GET status → 200 {"state":"ready","firmware":"5.0.1","uptimeS":0}
UI test (user reboot):
  t= 5s Restarting… reconnecting (attempt 1)
  t=10s Restarting… reconnecting (attempt 2)
  t=15s Restarting… reconnecting (attempt 3)
  t=20s Back online (firmware 5.0.1)
  t=25s Online
  t=30s Online
UI test (power loss):
  t= 5s Online
  t=10s Device unreachable
  t=15s Device unreachable
  t=20s Back online (firmware 5.0.1)
  t=25s Online
  t=30s Online
What’s happening
  1. createFakeClock replaces real time. setTimeout only records { at, fn }; nothing runs until the test calls advance(ms), which moves now forward and runs every timer that has come due, in order. That's why a 45-second reboot costs no real time and the output is identical on every run.
  2. The API test advances to t=600s, so uptimeS is 600. POST /api/v1/reboot answers 202 Accepted with a Location for the operation resource (the long-running-operation pattern from Long-running operations). The simulator powers down in res.on("finish"), after the response is sent, because a real device that dropped the connection before replying would leave the client unsure whether the reboot was accepted.
  3. At t=644s the device is still down, and the middleware's req.socket.destroy() makes supertest fail with ECONNRESET: no status code at all, which is what a client really gets from a rebooting device. At t=645s the boot timer has fired: state is ready and uptimeS is back to 0. The test proves both the API contract and the timing.
  4. The UI test starts the same simulator on a real ephemeral port (listen(0)), and the banner logic uses fetch exactly as the browser code would. In the "user reboot" run the UI knows it asked for the reboot, so failed polls show Restarting… reconnecting (attempt n); with bootMs 20 s, the poll at t=20s succeeds and shows Back online, then plain Online.
  5. In the "power loss" run nobody asked for a reboot. The first failed poll at t=5s still shows Online on purpose: a single failed request is often a blip, so the banner waits for two failures before saying Device unreachable. The same simulator, with one fault control (powerCycle()), exercised both branches; on a real device you'd need someone to pull a plug.
  6. What this demonstrates: the simulator is shared infrastructure. One state machine with fake time and fault controls serves API tests, UI tests and demos, and every team tests the reboot flow the same way.
AdvancedWhat makes a simulator worth trusting

A simulator that behaves better than the real device is worse than none, because it trains everyone's code to expect perfection. The properties that keep it honest:

  • The interface comes from the spec. Routes, status codes, headers and body shapes are generated from openapi.yaml or checked against it with the same contract test as the device (the previous topic). A simulator response that fails the contract is a simulator bug.
  • A real state machine, not canned answers. Settings persist between requests; a setting marked "restart required" stays pending until a reboot; an update moves through download, verify, install, reboot. UI flows like those in Device unreachable, rebooting and offline states can't be tested without state.
  • Realistic defaults. Reboots take as long as the real device's (measure it), responses have some latency, the connection limit is the real one. Faults are off by default but one call away.
  • A separate control surface. Tests trigger faults through an API that the real device doesn't have (/__sim/power-cycle, or a function call in process), kept apart from the device API so no client code can accidentally depend on it.
  • Determinism. A fake or controllable clock and seeded randomness, so a failing test fails the same way when someone in another time zone re-runs it.
  • Cheap to run many. A cloud fleet-management service needs hundreds of devices checking in; a simulator that starts in milliseconds and uses a few megabytes can be run five hundred times on one CI machine.
AdvancedChecking the simulator against the real device

Simulators drift. The firmware team adds a peripherals array to /status, and nobody tells the simulator. The defence is a parity check: in the lab, record real responses from real devices for a fixed list of calls (status, headers, body), save them as fixtures, and compare the simulator's responses with them in CI. Values legitimately differ (serial numbers, uptimes, operation ids), so compare shapes: the same keys, the same kinds of value, the headers clients rely on.

sim-parity.mjs
// Simulator parity: compare what the simulator returns with responses recorded from a real device in the lab.
// The recorded fixtures are illustrative stand-ins for files a lab run would save (status, headers, body).
const recorded = {
  "GET /api/v1/status": { status: 200, headers: { "content-type": "application/json" }, body: { state: "ready", firmware: "5.0.1", uptimeS: 600, peripherals: [{ type: "camera", connected: true }] } },
  "POST /api/v1/reboot": { status: 202, headers: { "content-type": "application/json", location: "/api/v1/operations/7", "retry-after": "45" }, body: { status: "accepted" } },
};
const simulated = {
  "GET /api/v1/status": { status: 200, headers: { "content-type": "application/json" }, body: { state: "ready", firmware: "5.0.1", uptimeS: 600.25 } },
  "POST /api/v1/reboot": { status: 202, headers: { "content-type": "application/json", location: "/api/v1/operations/1" }, body: { status: "accepted" } },
};

const kind = (v) => (v === null ? "null" : Array.isArray(v) ? "array" : Number.isInteger(v) ? "integer" : typeof v);
// Compare shapes, not values: the same keys with the same kinds, recursively. Values like ids and uptimes differ.
function diffShape(real, sim, path = "body") {
  const out = [];
  if (kind(real) !== kind(sim)) return [`${path}: real is ${kind(real)}, simulator is ${kind(sim)}`];
  if (kind(real) === "object") {
    for (const k of Object.keys(real)) {
      if (!(k in sim)) out.push(`${path}.${k}: missing in simulator`);
      else out.push(...diffShape(real[k], sim[k], `${path}.${k}`));
    }
    for (const k of Object.keys(sim)) if (!(k in real)) out.push(`${path}.${k}: only in simulator`);
  }
  if (kind(real) === "array" && real.length && sim.length) out.push(...diffShape(real[0], sim[0], `${path}[0]`));
  return out;
}
const HEADERS = ["content-type", "location", "retry-after"]; // headers clients depend on

let total = 0;
for (const [call, real] of Object.entries(recorded)) {
  const sim = simulated[call];
  const diffs = [];
  if (real.status !== sim.status) diffs.push(`status: real ${real.status}, simulator ${sim.status}`);
  for (const h of HEADERS) if ((h in real.headers) !== (h in sim.headers)) diffs.push(`header ${h}: ${h in real.headers ? "missing in simulator" : "only in simulator"}`);
  diffs.push(...diffShape(real.body, sim.body));
  total += diffs.length;
  console.log(`${call}: ${diffs.length ? diffs.length + " difference(s)" : "matches"}`);
  for (const d of diffs) console.log(`  - ${d}`);
}
console.log(total ? `parity check FAILED: fix the simulator (or the fixture) before trusting tests that use it` : "parity check passed");
Result
GET /api/v1/status: 2 difference(s)
  - body.uptimeS: real is integer, simulator is number
  - body.peripherals: missing in simulator
POST /api/v1/reboot: 1 difference(s)
  - header retry-after: missing in simulator
parity check FAILED: fix the simulator (or the fixture) before trusting tests that use it
What’s happening
  1. recorded stands in for fixtures a lab run would save from a real device; simulated is what the simulator answers for the same calls. Both hold status, the headers that matter and the body. (The fixtures here are illustrative; in a real setup a nightly lab job refreshes them for each firmware version.)
  2. kind classifies values, and treats integers separately from other numbers because JSON Schema and many clients do. diffShape walks the real body and the simulated one together: keys missing on either side, and keys whose kinds differ. It ignores values, so operations/7 vs operations/1 and different uptimes don't count.
  3. GET /status shows two real drifts. The simulator computes uptimeS by dividing milliseconds by 1000, so it can return 600.25 where the device returns whole seconds: a UI that formats 600 as "10 min" might show "10.004166 min". And the firmware added peripherals, which the simulator has never heard of, so the UI's camera-status panel can't be tested against it.
  4. The reboot response lacks Retry-After: 45, which the real device sends to tell clients when to come back. A UI that uses it to time its first reconnect attempt would behave differently on the simulator than in the field.
  5. The check ends by failing CI, which is the point: a simulator is only as useful as its parity with the device. Tests that pass against a drifted simulator prove nothing about the product.
AdvancedThe Android Emulator and Cuttlefish

For the Android side of a device (the system apps, services and permissions from Integrating with Android and the Platform), the stand-in is an emulator running a real Android system image. Two are documented publicly:

  • The Android Emulator runs an Android Virtual Device (AVD) built from a system image for a chosen API level: API 29 for Android 10, API 30 for Android 11 and API 33 for Android 13, so you can test the same app against each OS version of a 4.x→5.x migration (HP's public 5.0.0 notes say the second-generation systems go from Android 10 to 13; the role description says 11 to 13, see Poly video devices and PolyOS in brief). The command-line reference (developer.android.com, checked October 2026) documents -no-window "for servers that have no display", -no-snapshot for a full cold boot that loses state on exit, -wipe-data to return the device to its first-boot state, and -netdelay / -netspeed to emulate network conditions. The same page says the standalone emulator tool is deprecated in favour of the Android CLI's android emulator command, so check which one your CI images ship.
  • Cuttlefish (source.android.com, checked October 2026) is a "configurable virtual Android-powered device" for platform work, running in the cloud or on local Linux x86 and ARM64 machines, aiming to "replicate the framework-based behavior of a real device with a focus on high fidelity." It's what platform teams use when they build Android itself, and continuous integration is one of its listed uses.

The emulator also has a control channel that will look familiar after the Telnet chapter: an emulator console reached with telnet localhost 5554, which accepts connections from localhost only and requires auth <token> with the token from ~/.emulator_console_auth_token (developer.android.com, "Send emulator console commands", checked October 2026). The page's comparison table describes this Telnet console as "Unencrypted; local ports (5554–5585)", against a gRPC interface that can be TLS-secured, so treat it as local-only. Its commands are fault injection for an Android device:

Shell
# Fault injection on a running emulator (commands from the emulator console docs; not run here).
adb emu network delay gprs          # add GPRS-like latency
adb emu network speed 14.4 80       # throttle to 14.4 kbit/s up, 80 kbit/s down
adb emu avd snapshot save clean     # save a known-good state…
adb emu avd snapshot load clean     # …and return to it between tests
adb emu kill                        # stop the emulator

adb emu <command> sends one console command without an interactive session; with several emulators running, adb -s emulator-5556 emu … picks one. Snapshots let every test start from the same known state without going through a full boot and setup each time.

What emulators can't tell you is just as important. They don't have your device's camera modules, codecs, HDMI capture, audio DSP or the OEM's own hardware abstraction layers, and a vendor's customised OS isn't a stock AVD image. Poly's public documentation doesn't say whether PolyOS is available as an emulator image, so don't assume it; the general pattern for OEMs is to build an emulator or Cuttlefish target of their own platform for framework-level testing, and keep hardware questions for the lab.

Why "Online" at t=5s?

In the power-loss run, the device is already down at t=5s, yet the banner still says Online. Is that a bug? What would you change if product asked for faster feedback?

Show answer

It's deliberate debouncing: the banner waits for two consecutive failed polls before claiming the device is unreachable.

What’s happening
  1. At t=5s the poll's fetch fails (the socket is destroyed), so failures goes from 0 to 1. rebootRequested is false, so the banner expression evaluates failures >= 2 ? "Device unreachable" : "Online" and keeps Online.
  2. At t=10s the second failure makes failures 2, and the banner switches to Device unreachable. On real networks a single failed request is common (Wi-Fi roaming, a proxy hiccup), and a banner that flickers on every blip trains admins to ignore it.
  3. The cost is up to one extra polling interval (5 s here) before the operator is told. For faster feedback without flicker, retry the failed request once after a short delay (say 1 s) inside the same poll, or show a softer "Checking connection…" state after the first failure and the red banner after the second.
  4. The simulator is what makes this testable: powerCycle() produces exactly the failure sequence, at exactly the simulated times, on every run.

#Hardware-in-the-loop and lab testing

Added

Some bugs only exist in the physical world. The update that bricks a device when the power goes off at 60%. The camera that isn't detected when it's plugged in during boot rather than after. The HDMI display that negotiates a different resolution after the TV wakes from standby. Thermal throttling that makes the encoder drop frames after forty minutes in a warm room. Audio echo that depends on the room. No simulator or emulator has these, because they come from hardware, firmware below Android, and physics. Hardware-in-the-loop (HIL) testing puts a real device (the device under test, DUT) into a rig where software controls everything around it, its power, network, peripherals and inputs, and observes everything it does, its console, logs, API, screen and sound. A lab is many such rigs run as a shared service: scheduled, health-checked, and available to every developer and to CI.

The mental model is a crash-test track. You don't learn how a car behaves in a crash from a simulation alone, so you build a track where a real car hits a real barrier under controlled, instrumented, repeatable conditions, and you do it for every model. HIL is the same: real hardware, controlled conditions, instruments on everything, the same scenario run the same way each time. And just as a crash test isn't how you check that the radio works, HIL isn't where you test API validation: it's for the questions only hardware can answer.

Lab scheduler jobs → free rigs Results store logs, video, verdicts Host controller test harness (Tradefed, LAVA, or your own) Smart power switch on, off, cut mid-update Network + impairment loss, latency, port down Switchable USB hub camera, mic plug/unplug HDMI capture + mic what the room sees, hears Serial, adb, REST API console, logs, state Device under test real hardware, real firmware camera, display, speakers reset to a known state before every job
Controls on the left of the device, observations back to the harness; the scheduler and results store turn rigs into a lab.

The pieces are well known from public projects. Android's own test infrastructure is built on Trade Federation (Tradefed), which its README describes as "a test harness used to drive Android automated testing" that "runs on test hosts and monitors the connected devices"; the Compatibility Test Suite (CTS) and Vendor Test Suite (VTS) extend it, and cts-tradefed can shard one run across several devices in parallel (AOSP tools/tradefederation and cts/tools/cts-tradefed READMEs, checked October 2026). LAVA, the Linaro Automated Validation Architecture, is "a continuous integration system for deploying operating systems onto physical and virtual hardware for running tests" (docs.lavasoftware.org, checked October 2026); its power-control client pduclient (the pduclient(1) man page, from the lavapdu / PDUDaemon packages) sends on, off and reboot commands to a specific port on a power distribution unit, which is how a lab gives a board a hard reboot. Hosted device labs exist too: Firebase Test Lab runs apps on "real, production devices running in a Google data center", though its page (checked October 2026) says it is deprecated and will shut down on 30 September 2027, and such services host phones, not your conference-room hardware. Poly's public documentation doesn't describe its internal labs, so everything below is the general pattern, not a description of HP's.

What belongs in HIL, and how the rig provides it:

QuestionExample testThe rig needs
Does it survive power loss?Cut power at every step of a firmware update; the device must boot a working systemSwitchable power, serial console to watch the boot
Do peripherals work in every order?Plug the camera before boot, after boot, during a call; unplug mid-callSwitchable USB hub, API or adb to read device state
Is the output right?After a resolution change, the HDMI output shows the expected layout; audio plays at the set volumeHDMI capture card, microphone, image and audio comparison
Does it hold up over time?72-hour soak: calls every 10 minutes, memory and temperature loggedLong rig reservations, metrics collection
Does it cope with real networks?3% packet loss, 300 ms latency, DHCP lease renewal, the switch port going downManaged switch, network impairment (Linux tc netem)
Does the update path work from every supported version?Update from each field version (4.x and 5.x) to the release candidateA device pool covering the versions in the field, re-flashing between jobs

The model below is the first row, written the way a HIL test is designed: a matrix of power-cut points. It models a device with the A/B (seamless) update scheme that Android documents (source.android.com, "A/B (seamless) system updates", checked October 2026): two sets of partitions called slots, so a working system always remains on disk; the update never writes to the slot that's running; each slot has a bootable flag; and a newly updated slot only counts as successful after the first boot passes checks (Android's update_verifier daemon does this), so a failure can fall back to the old slot. Three updaters are tested: an old-style in-place updater with one system partition, a correct A/B updater, and an A/B updater with one subtle bug.

power-cut-matrix.mjs
// A hardware-in-the-loop style matrix: cut power after every step of a firmware update, then boot and see what runs.
// The device model follows the A/B scheme: two slots, a "bootable" flag, a "successful" flag and a boot-try counter.
const newDevice = (slotB) => ({
  active: "A",
  slots: { A: { image: "v1", bootable: true, successful: true, tries: 0 }, B: slotB ? { image: "v1", bootable: true, successful: true, tries: 0 } : { image: null, bootable: false } },
});
const other = (s) => (s === "A" ? "B" : "A");

// Three updaters. Each is a list of steps; the rig may cut power after any of them.
const updaters = {
  "in-place": [
    ["download", () => {}],
    ["write 50%", (d) => { d.slots[d.active].image = "partial"; }],
    ["write 100%", (d) => { d.slots[d.active].image = "v2"; }],
  ],
  "A/B": [
    ["download", () => {}],
    ["mark B unbootable", (d) => { d.slots.B.bootable = false; }],
    ["write B 50%", (d) => { d.slots.B.image = "partial"; }],
    ["write B 100%", (d) => { d.slots.B.image = "v2"; }],
    ["set B active", (d) => { Object.assign(d.slots.B, { bootable: true, successful: false, tries: 3 }); d.active = "B"; }],
  ],
  "A/B, successful too early": [
    ["download", () => {}],
    ["mark B unbootable", (d) => { d.slots.B.bootable = false; }],
    ["write B 50%", (d) => { d.slots.B.image = "partial"; }],
    ["write B 100%", (d) => { d.slots.B.image = "v2"; }],
    ["set B active", (d) => { Object.assign(d.slots.B, { bootable: true, successful: true, tries: 0 }); d.active = "B"; }],
  ],
};

// Bootloader + first boot: a slot that isn't marked successful uses up a try per boot; the health check
// (update_verifier's role on Android, plus our own app checks) marks it successful only if the new build works.
function boot(d, v2Works) {
  for (let attempt = 0; attempt < 6; attempt++) {
    const slot = d.slots[d.active];
    if (!slot.bootable) { d.active = other(d.active); continue; }
    if (!slot.successful) { if (slot.tries === 0) { slot.bootable = false; continue; } slot.tries--; }
    const healthy = slot.image === "v1" || (slot.image === "v2" && v2Works);
    if (healthy) { slot.successful = true; return `runs ${slot.image}`; }
    // partial image or broken build: the device crashes and reboots
  }
  return d.slots.A.image === "partial" || d.slots.B.image === "partial" ? "BRICKED" : "BOOT LOOP";
}

for (const [name, steps] of Object.entries(updaters)) {
  console.log(`${name.padEnd(30)}v2 good    v2 bad`);
  for (let cut = 1; cut <= steps.length; cut++) {
    const results = [true, false].map((v2Works) => {
      const d = newDevice(name !== "in-place"); // an in-place device has a single system slot
      steps.slice(0, cut).forEach(([, step]) => step(d)); // run the update up to the cut, then power off
      return boot(d, v2Works).padEnd(10);
    });
    const when = cut === steps.length ? "no cut (update completes)" : `cut after ${steps[cut - 1][0]}`;
    console.log(`  ${when.padEnd(28)}${results.join(" ")}`.trimEnd());
  }
}
Result
in-place                      v2 good    v2 bad
  cut after download          runs v1    runs v1
  cut after write 50%         BRICKED    BRICKED
  no cut (update completes)   runs v2    BOOT LOOP
A/B                           v2 good    v2 bad
  cut after download          runs v1    runs v1
  cut after mark B unbootable runs v1    runs v1
  cut after write B 50%       runs v1    runs v1
  cut after write B 100%      runs v1    runs v1
  no cut (update completes)   runs v2    runs v1
A/B, successful too early     v2 good    v2 bad
  cut after download          runs v1    runs v1
  cut after mark B unbootable runs v1    runs v1
  cut after write B 50%       runs v1    runs v1
  cut after write B 100%      runs v1    runs v1
  no cut (update completes)   runs v2    BOOT LOOP
What’s happening
  1. Each updater is a list of named steps. For every cut point, the rig starts from a fresh device running v1, runs the steps up to and including that one, then "cuts the power" (stops) and boots. It does this twice: once where v2 is a good build and once where v2 boots but fails its health check (a crash in the call service, say). In a real lab, the smart power switch does the cut and the serial console shows where the boot ends up.
  2. boot models the bootloader. It skips an unbootable slot and switches to the other; for a slot that isn't yet marked successful it uses up one of its tries, and when they reach 0 it marks the slot unbootable and falls back. A healthy boot marks the slot successful. A partial image or a bad v2 crashes, and the loop tries again, up to six boots.
  3. In-place writes over the only system partition. Cut after "write 50%" and the only image is partial: nothing can boot, so BRICKED (in the field, that's a truck roll or a return). Even without a power cut, a bad v2 leaves no older system to go back to: BOOT LOOP. This is why devices moved to A/B.
  4. A/B never touches the running slot. Every cut before "set B active" leaves slot A active and untouched, so the device runs v1, and the update can simply be retried. With a bad v2, slot B boots with tries 3 → 2 → 1 → 0, never passes the health check, gets marked unbootable, and the bootloader falls back to A: runs v1, the automatic rollback the A/B design promises.
  5. A/B, successful too early differs by one value: it sets successful: true when it activates B, skipping the post-boot check. Every power cut is still survived, so a power-cut test alone would pass it. Only the "bad v2" column shows the bug: B is "successful", so tries are never counted, the bootloader never falls back, and the device sits in a BOOT LOOP with a perfectly good v1 on the other slot.
  6. What this demonstrates about HIL design: the value is in the matrix (every cut point × good and bad builds), not one happy-path run. The third updater would pass a test plan that only cuts power, and it would ship a fleet-wide rollback failure.
AdvancedRunning a lab

A rig is a test; a lab is a service, and it needs the same care as production:

  • Reset to a known state before every job. Flash the target build, factory-reset settings, re-pair peripherals, and verify with a health check before the test starts. A test that inherits the previous job's state passes or fails for reasons nobody can reproduce.
  • Health-check the rigs themselves. Cables, hubs and capture cards fail. A short health job (boot, read the serial console, enumerate the camera, capture one HDMI frame) runs between jobs; a rig that fails it is quarantined automatically, so its failures aren't blamed on the build.
  • Schedule by capability. Jobs ask for what they need ("a room-bar on 4.x with a USB camera and HDMI capture"), and the scheduler matches them to rigs that have it, so tests don't hard-code rig names.
  • Tier the cadence. Simulator and emulator tests on every pull request; a short HIL smoke suite (boot, update, call, reboot) on every merge to main; the full matrix, the update-from-every-field-version suite and long soak tests nightly and before each release.
  • Make it reachable from anywhere. For a team split between the US and India, the lab is how everyone gets real hardware: reserve a rig from a web page, get its serial console and adb in the browser or over a tunnel, see the HDMI capture live. That turns "send me a device" into "book rig 7 for two hours".
  • Keep everything a failure produces. Serial log, logcat, an Android bug report (adb bugreport, see Feature flags, telemetry and field diagnostics), the captured video, the exact build and rig id. A HIL failure that can't be investigated the next morning will be dismissed as flaky.
AdvancedTriage: regression, bad device or flaky test?

A nightly lab run across many rigs produces failures with three very different causes, and treating them alike wastes days. A regression fails everywhere. A bad rig fails tests that pass on every other rig. A flaky test fails, then passes when re-run on the same rig. The standard approach is to re-run failures a limited number of times and then look at the pattern across rigs. The results below are illustrative, shaped like a real nightly run.

lab-triage.mjs
// Lab triage: after a nightly run on real devices, sort each failure into regression, bad device or flaky test.
// Results are illustrative: tests × devices, each failure re-run up to twice on the same device.
const runs = {
  // test name: { device: [first run, re-run 1, re-run 2] }  (true = pass)
  "reboot-recovers":       { rig01: [true], rig02: [true], rig03: [false, false, false], rig04: [true] },
  "apply-brightness":      { rig01: [true], rig02: [true], rig03: [false, false, false], rig04: [true] },
  "update-power-cut":      { rig01: [false, false, false], rig02: [false, false, false], rig03: [false, false, false], rig04: [false, false, false] },
  "camera-reconnect":      { rig01: [true], rig02: [false, true], rig03: [true], rig04: [false, false, true] },
  "hdmi-hotplug":          { rig01: [true], rig02: [true], rig03: [true], rig04: [true] },
};
const devices = ["rig01", "rig02", "rig03", "rig04"];

const verdict = (attempts) => (attempts[0] ? "pass" : attempts.some(Boolean) ? "flaky" : "fail");
const byDevice = Object.fromEntries(devices.map((d) => [d, 0]));
const report = [];
for (const [test, results] of Object.entries(runs)) {
  const v = Object.fromEntries(devices.map((d) => [d, verdict(results[d])]));
  const fails = devices.filter((d) => v[d] === "fail");
  if (fails.length < devices.length) fails.forEach((d) => byDevice[d]++); // a failure everywhere says nothing about one device
  report.push({ test, v, fails, flaky: devices.filter((d) => v[d] === "flaky") });
}
// A device that hard-fails tests that pass everywhere else is suspect; check it before blaming the build.
const suspect = devices.filter((d) => byDevice[d] >= 2);

console.log(`${"test".padEnd(17)} ${devices.join(" ")}`);
for (const { test, v, fails, flaky } of report) {
  let call;
  if (fails.length === devices.length) call = "REGRESSION: fails on every device, block the release";
  else if (fails.length && fails.every((d) => suspect.includes(d))) call = `bad device? only ${fails.join(", ")} (quarantine, run health check)`;
  else if (fails.length) call = `fails on ${fails.join(", ")}: investigate`;
  else if (flaky.length) call = `FLAKY on ${flaky.join(", ")}: passed on re-run, file a flake ticket`;
  else call = "pass";
  console.log(`${test.padEnd(17)} ${devices.map((d) => v[d].padEnd(5)).join(" ")}  ${call}`);
}
console.log(`quarantined: ${suspect.join(", ") || "none"} · device runs, including re-runs: ${Object.values(runs).flatMap((r) => Object.values(r)).flat().length}`);
Result
test              rig01 rig02 rig03 rig04
reboot-recovers   pass  pass  fail  pass   bad device? only rig03 (quarantine, run health check)
apply-brightness  pass  pass  fail  pass   bad device? only rig03 (quarantine, run health check)
update-power-cut  fail  fail  fail  fail   REGRESSION: fails on every device, block the release
camera-reconnect  pass  flaky pass  flaky  FLAKY on rig02, rig04: passed on re-run, file a flake ticket
hdmi-hotplug      pass  pass  pass  pass   pass
quarantined: rig03 · device runs, including re-runs: 35
What’s happening
  1. Each cell is the list of attempts on one rig: [true] passed first time; [false, true] failed and passed on the first re-run; [false, false, false] failed all three. verdict turns that into pass, flaky (failed, then passed on the same rig) or fail (never passed).
  2. byDevice counts hard failures per rig, but skips tests that fail on every rig: update-power-cut failing on all four says something about the build, not about any one rig. That leaves rig03 with 2 hard failures and the others with 0.
  3. update-power-cut fails on all four rigs in all twelve attempts, so it's a regression: block the release and look at the build. It's the kind of failure the power-cut matrix above is designed to find.
  4. reboot-recovers and apply-brightness fail only on rig03, consistently, while passing elsewhere. The likely cause is the rig (a loose power cable, a failing display), so it's quarantined and health-checked before anyone debugs the firmware.
  5. camera-reconnect failed on rig02 and rig04 but passed on re-run. That's a flaky test (or a real intermittent bug), which gets a ticket and a flake-rate metric. Hiding it behind automatic re-runs forever would mask a real race in camera enumeration.
  6. The cost of triage is runs: 35 device runs for 20 test-rig pairs. Limit re-runs (here two) so a broken build doesn't occupy the lab all night.

#CI/CD for web UI, APIs and firmware

Added

A device platform ships three kinds of artefact, on three cadences, often owned by three teams. The web UI is a bundle of static files that can be rebuilt in minutes. The API service (the on-device server, often an Android app or system service) is code that talks to the platform. The firmware or OS image (for an Android-based device, a full system build signed with the vendor's release keys) takes hours to build and is what actually lands on a device. On many devices the first two ship inside the third, so a one-line UI fix still reaches customers as a firmware release; some platforms can also update apps separately from firmware (Microsoft's Teams device documentation, for example, distinguishes app updates from firmware updates for Teams Android devices). Either way, the pipeline has the same job: turn a commit into a signed, traceable artefact, prove it, and get it onto a fleet gradually enough that a bad one stops itself.

The mental model is a factory line with gates, followed by a clinical trial. On the line, each station either passes the part on or rejects it: build, unit and contract tests, simulator end-to-end, hardware in the loop, packaging and signing. Nothing is rebuilt after it passes; the exact bytes that were tested are the bytes that ship. Then the release goes out like a new medicine: first to a small group you watch closely, compared against a control group that didn't get it, then to more people only if nothing bad shows up, with the authority to stop at any stage.

Commit PR to main Build UI · API · image Test gates contract · sim · HIL Sign + attest release keys, SHA-256 Artefacts immutable the same bytes go to every stage; nothing is rebuilt 1%soak 24 h 5%soak 48 h 20%soak 48 h 50%soak 72 h 100%done Health gate after each soak updated devices vs a control group not yet updated, overall and per model: continue, or HALT telemetry go / halt
Build and prove once, then widen the audience stage by stage while a gate compares updated devices with the rest.

Two public sources shape how a fleet rollout should behave. Google's SRE workbook chapter "Canarying Releases" (sre.google, checked October 2026) defines canarying as "a partial and time-limited deployment of a change in a service and its evaluation", with the rest of the population as the control; it recommends evaluating the top few metrics ("perhaps no more than a dozen"), starting from user-facing SLIs such as failed and slow responses, and comparing canary against control, and it warns that whole-population numbers hide a bad canary: a 20% error rate in a 5% canary shows up as only about 1% overall. And for conference-room devices specifically, Microsoft's Teams Rooms Pro Management portal documentation ("Transitioning updates management for Teams Android devices…", Microsoft Learn, updated August 2026) describes update rings with default durations of 0–6 days (Staging), 7–13 days (General) and 14–20 days (Executive), automatic updates only in a nightly maintenance window between 12 AM and 5 AM device local time, updates postponed while a device is busy, and a tenant-wide pause that lasts two weeks. That is what customers of room devices already expect from a vendor's rollout.

The model below is a staged-rollout planner for a 40,000-device fleet. It assigns each device a stable bucket from a hash of the release id and serial number, rolls out in five stages with a soak period each, and after every soak compares the failure rate of updated devices with a control group of devices that haven't been updated, overall and per hardware model. Fleet composition and failures are drawn from a seeded PRNG, so the output is identical on every run. Release 5.0.2 is healthy; release 5.0.3 has a bug that only affects touch panels.

rollout-planner.mjs
// A staged-rollout planner for a device fleet: stable buckets, soak per stage, a health gate against a control group.
import { createHash } from "node:crypto";

function mulberry32(seed) { // seeded PRNG: same fleet and same failures every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
// Stable bucket 0–9999 per (rollout, device): a device in the 5% stage stays in for 20%, 50% and 100%.
const bucket = (rolloutId, serial) => createHash("sha256").update(`${rolloutId}:${serial}`).digest().readUInt32BE(0) % 10_000;

const rand = mulberry32(2026);
const MODELS = ["room-bar", "codec", "touch-panel"]; // 60%, 30% and 10% of the fleet
const fleet = Array.from({ length: 40_000 }, (_, i) => {
  const r = rand();
  return { serial: `SN${String(i).padStart(6, "0")}`, model: MODELS[r < 0.6 ? 0 : r < 0.9 ? 1 : 2] };
});

const STAGES = [{ pct: 1, soakH: 24 }, { pct: 5, soakH: 48 }, { pct: 20, soakH: 48 }, { pct: 50, soakH: 72 }, { pct: 100 }];
const GATE = { minFailures: 5, minExcessPct: 0.5, minZ: 3, minSegment: 50 };
const BASE_FAILURE = 0.004; // chance a device on the old build has a failure (crash, failed call, reboot) in a soak

// Health gate: is the cohort's failure rate clearly worse than the control group's?
function judge(cohort, control) {
  const p1 = cohort.failed / cohort.n, p0 = control.failed / control.n;
  const z = (p1 - p0) / Math.sqrt((p0 * (1 - p0)) / cohort.n);
  const bad = cohort.failed >= GATE.minFailures && (p1 - p0) * 100 >= GATE.minExcessPct && z >= GATE.minZ;
  return { bad, text: `${cohort.failed}/${cohort.n} = ${(100 * p1).toFixed(2)}% vs control ${(100 * p0).toFixed(2)}%` };
}

function rollOut(version, failureChance) {
  console.log(`Release ${version}`);
  let hours = 0;
  for (const [i, stage] of STAGES.entries()) {
    const inStage = (d) => bucket(version, d.serial) < stage.pct * 100;
    const cohort = fleet.filter(inStage);
    if (!stage.soakH) { console.log(`  stage ${i + 1} (${stage.pct}%): ${cohort.length} devices, rollout complete after ${hours / 24} days`); return; }
    hours += stage.soakH;
    // Soak: about 92% of updated devices report back in time; every device may fail with its build's chance.
    const report = (d, chance) => ({ ...d, reported: rand() < 0.92, failed: rand() < chance });
    const updated = cohort.map((d) => report(d, failureChance(d.model))).filter((d) => d.reported);
    const control = fleet.filter((d) => !inStage(d)).map((d) => report(d, BASE_FAILURE)).filter((d) => d.reported);
    const count = (list) => ({ n: list.length, failed: list.filter((d) => d.failed).length });
    const overall = judge(count(updated), count(control));
    const segments = MODELS.map((m) => [m, count(updated.filter((d) => d.model === m)), count(control.filter((d) => d.model === m))]);
    console.log(`  stage ${i + 1} (${stage.pct}%): ${cohort.length} updated, ${updated.length} reported`);
    console.log(`      all models   ${overall.text} ${overall.bad ? "✗" : "✓"}`);
    let failing = 0;
    for (const [m, c, k] of segments) {
      const j = c.n >= GATE.minSegment ? judge(c, k) : null; // too few devices to judge this model yet
      if (j?.bad) failing++;
      if (!j || j.bad) console.log(`      ${m.padEnd(12)} ${j ? j.text + " ✗" : `${c.n} reported, too few to judge`}`);
    }
    if (overall.bad || failing) {
      console.log(`  HALT on day ${hours / 24}: ${cohort.length} devices have ${version}, ${fleet.length - cohort.length} never got it`);
      return;
    }
  }
}

rollOut("5.0.2", () => BASE_FAILURE); // healthy release
rollOut("5.0.3", (model) => (model === "touch-panel" ? 0.04 : BASE_FAILURE)); // a bug on one model only
Result
Release 5.0.2
  stage 1 (1%): 381 updated, 350 reported
      all models   0/350 = 0.00% vs control 0.36% ✓
      touch-panel  29 reported, too few to judge
  stage 2 (5%): 2008 updated, 1832 reported
      all models   7/1832 = 0.38% vs control 0.39% ✓
  stage 3 (20%): 8032 updated, 7410 reported
      all models   23/7410 = 0.31% vs control 0.43% ✓
  stage 4 (50%): 20037 updated, 18404 reported
      all models   78/18404 = 0.42% vs control 0.40% ✓
  stage 5 (100%): 40000 devices, rollout complete after 8 days
Release 5.0.3
  stage 1 (1%): 377 updated, 342 reported
      all models   3/342 = 0.88% vs control 0.31% ✓
      touch-panel  31 reported, too few to judge
  stage 2 (5%): 2079 updated, 1905 reported
      all models   16/1905 = 0.84% vs control 0.40% ✓
      touch-panel  9/192 = 4.69% vs control 0.52% ✗
  HALT on day 3: 2079 devices have 5.0.3, 37921 never got it
What’s happening
  1. bucket hashes "<release>:<serial>" with SHA-256 and maps it to 0–9999. A stage of 5% takes buckets below 500, 20% below 2000, so every device in an earlier stage is in every later one: nobody gets the update at 5%, loses it at 20%, and gets it again. Including the release id in the hash means a different 1% goes first for each release, so the same customers aren't always the guinea pigs. (Stage sizes come out near, not exactly at, the percentage: 381 devices for 1% of 40,000, because hashes aren't perfectly even.)
  2. During each soak, about 92% of updated devices report back (the rest are switched off, offline or behind a firewall), so the gate judges reporting devices only: 350 of 381 at stage 1. The control group is every device not in the stage, measured over the same period, so a bad day for the whole fleet (a cloud outage, a network problem) raises both rates and doesn't halt the rollout on its own.
  3. judge needs three things before it calls a cohort bad: at least 5 failures, at least 0.5 percentage points worse than control, and a z-score of at least 3 (the excess divided by the standard error expected from the control rate). The three together stop tiny samples from halting on noise and stop big samples from halting on statistically real but harmless differences. These thresholds are illustrative; real ones come from your own history of good and bad releases (the SRE workbook makes the related point that canary parameters should be chosen from typical canary evaluation failure rates rather than worst-case assumptions).
  4. Release 5.0.2 runs all five stages. Its rates wander around the control's (0.31% to 0.42% against 0.36% to 0.43%) and never cross the gate, and the rollout completes after 24 + 48 + 48 + 72 hours = 8 days.
  5. Release 5.0.3 fails touch panels 4% of the time. At stage 1 only 31 touch panels reported, below the 50-device minimum, so that model can't be judged yet. At stage 2 the whole-cohort rate is 0.84% against 0.40%: z is about 3.0, but the excess is 0.44 points, under the 0.5 threshold, so the overall check passes. The per-model check finds touch panels at 9/192 = 4.69% against 0.52% (z about 8), and halts.
  6. The halt happens on day 3 with 2,079 devices updated and 37,921 never exposed. Without the per-model segment, the expected overall rate at 20% and 50% would have been about 0.76% (0.9 × 0.4% + 0.1 × 4%), still under the threshold, and the bug would have reached every touch panel. That is the SRE workbook's warning in device terms: aggregate numbers dilute a failure that's concentrated in one segment, so slice by model, firmware path, region and customer.
AdvancedWhat else a device rollout must respect
  • Maintenance windows and busy devices. Install only in the customer's window (the Teams Rooms default is 12 AM–5 AM local), never during a call, and reschedule rather than force. A conference room that reboots during a board meeting generates more support calls than a slow rollout.
  • Update paths, not just versions. Devices in the field run many versions, and some can't jump straight to the newest: an updater format changed, or a migration must run first. The planner must know which "stepping-stone" build each version needs, and HIL must test each path (see the matrix in Hardware-in-the-loop and lab testing).
  • Rollback is limited. A/B slots give automatic rollback when the new build fails its first boot, but after a build is marked successful, going back usually means shipping a newer build: Android Verified Boot keeps a rollback index in tamper-evident storage and refuses images with a lower index (AVB README, android.googlesource.com, checked October 2026), so a security fix can't be undone by flashing the old image. Plan to fix forward, and keep halting cheap.
  • Customer control. Enterprise customers want to pin versions, choose their own rings ("our test room first") and pause. The vendor's rollout and the customer's policy both apply; the device takes the stricter of the two.
  • The web UI is cached in browsers. If the UI is served by the device, a browser tab left open on an admin's laptop keeps running the old JavaScript against the new API after the update. Versioned asset names, a version check on reconnect and a "reload to update" prompt handle it; API compatibility rules (old UI, new API) handle the rest.
AdvancedThe pipeline as a GitHub Actions workflow

The workflow below is an illustrative shape for the web UI and API part of the pipeline, checked with actionlint 1.7.12 (it validates the workflow's syntax, expressions, job dependencies and permissions, not whether the scripts it calls exist). Action versions are the current majors as of October 2026 (actions/checkout@v7, actions/setup-node@v7, actions/upload-artifact@v7, actions/download-artifact@v8, actions/attest@v4, oasdiff/oasdiff-action v0.1.20). The Tech Lead site covers workflow design in depth; here the point is which gate sits where.

.github/workflows/device-software.yml
name: device-software
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  web-ui:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: web-ui
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: web-ui/package-lock.json
      - run: npm ci
      - run: npm run lint
      - run: npm test -- --run
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: web-ui
          path: web-ui/dist/

  api:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: api
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: api/package-lock.json
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:integration   # supertest against the Express app
      - run: npm run test:contract      # every response checked against openapi.yaml

  api-breaking-changes:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: git fetch --depth=1 origin "$BASE_REF"
        env:
          BASE_REF: ${{ github.base_ref }}
      - uses: oasdiff/oasdiff-action/breaking@v0.1.20
        with:
          base: origin/${{ github.base_ref }}:api/openapi.yaml
          revision: HEAD:api/openapi.yaml
          fail-on: ERR
          review: false

  simulator-e2e:
    needs: [web-ui, api]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - uses: actions/download-artifact@v8
        with:
          name: web-ui
          path: e2e/web-ui-dist
      - run: npm ci
        working-directory: e2e
      - run: npx playwright install --with-deps chromium
        working-directory: e2e
      - run: npm test   # starts the device simulator, serves the built UI, drives it in Chromium
        working-directory: e2e

  package:
    if: github.event_name == 'push'
    needs: [simulator-e2e]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      attestations: write
      artifact-metadata: write
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          name: web-ui
          path: bundle/web-ui
      - run: ./scripts/make-bundle.sh "bundle" "device-web-${GITHUB_SHA::12}.tar.gz"
      - run: sha256sum device-web-*.tar.gz > SHA256SUMS
      - uses: actions/attest@v4
        with:
          subject-path: device-web-*.tar.gz
      - uses: actions/upload-artifact@v7
        with:
          name: device-web-bundle
          path: |
            device-web-*.tar.gz
            SHA256SUMS
What’s happening
  1. permissions: contents: read at the top gives every job a read-only token by default; only package asks for more, and only what attestation needs. Least privilege in CI matters for device software, because a compromised pipeline is a way into every device it builds for.
  2. web-ui and api run in parallel. The UI job lints, runs its unit tests, builds, and uploads dist/ as an artefact, so later jobs use the built UI rather than rebuilding it. The API job runs the three levels from Testing APIs: unit, integration with supertest, and contract against openapi.yaml.
  3. api-breaking-changes runs only on pull requests. It fetches the base branch and lets oasdiff compare api/openapi.yaml on the base with the PR's version, failing on error-level (breaking) changes. review: false keeps the spec inside CI (the action's default would upload an encrypted copy to build a review page). Passing github.base_ref through env: rather than writing the expression into the shell command is the safe habit for any context value used in a script, because the shell then sees it as data, not code.
  4. simulator-e2e waits for both (needs), downloads the exact UI bundle the first job built, and runs browser tests against the device simulator: the reboot, update and offline flows from Device simulators and emulators.
  5. package runs only on pushes to main, after everything passed. It bundles the tested UI, writes SHA256SUMS, and calls actions/attest@v4, which (its README, checked October 2026) creates a SLSA build-provenance attestation signed with a short-lived Sigstore certificate; that needs id-token: write, attestations: write and artifact-metadata: write, and for private repositories a GitHub Enterprise Cloud plan. Anyone can later check where a bundle came from with gh attestation verify.
  6. What's deliberately not here: the firmware build and its release-key signing. A full Android image build usually runs on dedicated build infrastructure, and release keys belong in a hardware security module behind a signing service that CI asks to sign an artefact by hash, with approvals, rather than in a secret any workflow can read.
AdvancedSigning and verifying what you ship

Android's "Sign builds for release" page (source.android.com, checked October 2026) explains why signing can't be an afterthought: the default test keys are publicly known, so "anybody can sign their own .apk files with the same keys", and any image that is publicly released or deployed must be signed with private release keys; OTA packages must be signed with a key the system expects, or installation rejects them. The same idea applies to any artefact a device installs. The pipeline signs a manifest (what the artefact is, which model it's for, its version and its hash), and the device checks all of it before writing a byte. The model uses Node's built-in Ed25519 with a key derived from a fixed seed, so the output is reproducible; a real key never leaves its HSM. Update security from the device's side is covered in Supply chain and update security.

signed-manifest.mjs
// CI signs an update manifest; the device verifies it before installing. Ed25519 signatures are deterministic,
// and the key is built from a fixed seed here so the output is the same every run (a real key lives in an HSM).
import { createPrivateKey, createPublicKey, sign, verify, createHash } from "node:crypto";

const seed = Buffer.alloc(32, 7); // demo only
const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]), format: "der", type: "pkcs8" });
const publicKey = createPublicKey(privateKey); // baked into the device's firmware
const otherKey = createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.alloc(32, 9)]), format: "der", type: "pkcs8" });

const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const canonical = (obj) => JSON.stringify(obj, Object.keys(obj).sort()); // sign exactly one byte sequence

// ---- In CI: build once, hash, sign the manifest ----
function release(version, image, key = privateKey) {
  const manifest = { model: "room-bar", version, sha256: sha256(image), size: image.length };
  return { manifest, signature: sign(null, Buffer.from(canonical(manifest)), key).toString("base64"), image };
}

// ---- On the device: verify everything before writing a single byte to the inactive slot ----
const versionNum = (v) => v.split(".").reduce((n, part) => n * 1000 + Number(part), 0);
function verifyUpdate({ manifest, signature, image }, device) {
  if (!verify(null, Buffer.from(canonical(manifest)), publicKey, Buffer.from(signature, "base64"))) return "REJECT: bad signature";
  if (manifest.model !== device.model) return `REJECT: built for ${manifest.model}, this is ${device.model}`;
  if (versionNum(manifest.version) <= versionNum(device.version)) return `REJECT: ${manifest.version} is not newer than ${device.version} (anti-rollback)`;
  if (image.length !== manifest.size || sha256(image) !== manifest.sha256) return "REJECT: image hash does not match manifest";
  return `INSTALL ${manifest.version}`;
}

const device = { model: "room-bar", version: "5.0.2" };
const good = release("5.0.3", Buffer.from("firmware image bytes for 5.0.3"));
console.log(`manifest sha256 ${good.manifest.sha256.slice(0, 16)}…, signature ${good.signature.slice(0, 16)}…`);

const flipped = Buffer.from(good.image); flipped[5] ^= 1;
const cases = {
  "genuine 5.0.3": good,
  "image changed in transit": { ...good, image: flipped },
  "manifest edited (model)": { ...good, manifest: { ...good.manifest, model: "codec" } },
  "old 5.0.1 (genuinely signed)": release("5.0.1", Buffer.from("old image")),
  "signed with another key": release("5.0.3", good.image, otherKey),
};
for (const [name, pkg] of Object.entries(cases)) console.log(`${name.padEnd(30)} → ${verifyUpdate(pkg, device)}`);
Result
manifest sha256 aefc89a2af203887…, signature kXUZD404YW9yhPYi…
genuine 5.0.3                  → INSTALL 5.0.3
image changed in transit       → REJECT: image hash does not match manifest
manifest edited (model)        → REJECT: bad signature
old 5.0.1 (genuinely signed)   → REJECT: 5.0.1 is not newer than 5.0.2 (anti-rollback)
signed with another key        → REJECT: bad signature
What’s happening
  1. The private key is built from a 32-byte seed wrapped in the fixed PKCS#8 header for Ed25519 (302e…0420), and the device's public key is derived from it. Ed25519 signatures are deterministic, so with a fixed key the signature kXUZD404… is the same on every run, which is why it can be printed here. In production the seed would be random and stay inside the HSM.
  2. release is the CI side: hash the image, put the hash, size, version and target model in a manifest, and sign the canonical JSON (keys sorted), so the signer and the verifier hash exactly the same bytes. Signing the manifest instead of the multi-gigabyte image keeps the expensive part (hashing) separate from the key operation.
  3. verifyUpdate is the device side, ordered from cheapest to most expensive. Signature first: anything not signed by our key is rejected outright. Then the model, so a codec build can't be installed on a room bar. Then the version must be newer than what's installed. Only then does it hash the downloaded image and compare.
  4. The genuine package installs. Flipping one bit of the image leaves the manifest valid but changes the hash: rejected. Editing the manifest's model to codec invalidates the signature: rejected, because the device can't tell a tampered manifest from a forged one and doesn't need to.
  5. The old 5.0.1 package is genuinely signed, and that's the attack anti-rollback stops: an attacker replays an old, valid update with a known vulnerability. Only the version rule catches it. The last case, signed with a different key (a leaked test key, a compromised build machine), fails the signature check.
  6. What this demonstrates: integrity (hash), authenticity (signature), applicability (model) and freshness (version) are four separate checks, and an update path needs all four.
Why the 5% canary looks fine fleet-wide

The SRE workbook's example: a new release goes to a 5% canary, and 20% of the canary's requests fail. The other 95% of the service is unaffected. What error rate do you see on a dashboard that only shows the whole service, and what should the gate compare instead?

Show answer

About 1% overall, which can look like noise; the gate should compare the canary's 20% with the control's rate.

What’s happening
  1. Take 10,000 requests: 5% of them, 500, hit the canary, and 20% of those fail: 100 failures. The other 9,500 hit the control and (ignoring background errors) don't fail.
  2. Fleet-wide that's 100 / 10,000 = 1%. On a dashboard that usually shows a little background noise, a 1% bump is easy to miss or explain away.
  3. Split by population, it's 100 / 500 = 20% for the canary against roughly 0% for the control, which no one would call noise.
  4. So the gate must break metrics down by population (canary vs control) and, for devices, by segment (model, firmware path, region), as the rollout model does with the touch-panel slice.

#Feature flags, telemetry and field diagnostics

Added

Once software is on thousands of devices in other people's rooms, three needs appear that no test suite covers. You need to change behaviour without shipping firmware: turn a new feature on for one customer, or turn a misbehaving one off everywhere, today. You need to know what the fleet is doing: which builds crash, how calls sound, whether the update you just rolled out made anything worse. And you need to understand one device: the one in a customer's boardroom that drops calls every afternoon, which you can't touch, can't see, and can't reproduce. Feature flags, telemetry and field diagnostics are the three tools, and on devices each works differently from its web equivalent.

The mental model is a fleet of company cars. Flags are the settings the fleet manager can change remotely: speed limiter on, new navigation feature on for the Bangalore office only. Telemetry is the telematics box reporting fuel, faults and mileage for every car, so the manager sees patterns: this model's gearbox fails at 40,000 km. Diagnostics is the mechanic's port under the dashboard: when one car has a problem, you plug in and read everything about that car. The car must still drive when the telematics box has no signal, which is the device rule: none of the three may depend on the network to keep the device working.

Flags on a device differ from flags in a web app in four ways:

  • Evaluated locally. The device downloads a flag configuration (versioned, ideally signed like any other config) and evaluates flags itself, from a cached copy. It must work offline, at boot, before the network is up, so every flag has a compiled-in default.
  • Targeted by hardware and firmware. Rules match on model, firmware version, region, customer tenant and the device's serial, not on a logged-in user.
  • Only code that's already shipped. A flag can switch between code paths that exist in the firmware; it can't deliver new code. Flags shorten the gap between "shipped" and "enabled", which is what lets firmware ship on a train while features turn on when they're ready.
  • Never a reason to crash. The OpenFeature specification (openfeature.dev, flag evaluation API, checked October 2026) puts it as requirement 1.4.10: client methods "MUST NOT throw exceptions, or otherwise abnormally terminate", and "flag evaluation calls must always return the default value in the event of abnormal execution". Every evaluation also reports a reason and, on failure, an error code, which is what lets you debug a flag from telemetry.

Pete Hodgson's article "Feature Toggles (aka Feature Flags)" on martinfowler.com (2016, revised October 2017) sorts flags into four kinds with different lifetimes: release toggles that hide unfinished work (short-lived), experiment toggles for A/B tests (hours to weeks), ops toggles that let operators degrade or switch off a feature, including long-lived kill switches, and permissioning toggles that give some customers different features (potentially years). The model below has one of each of the first, third and fourth kinds.

flags.mjs
// Feature-flag evaluation on a device: local, never throws, always returns a value and a reason.
// Reasons and error codes follow the OpenFeature names (TARGETING_MATCH, SPLIT, DISABLED, ERROR / FLAG_NOT_FOUND…).
import { createHash } from "node:crypto";

// The flag config the device last downloaded (signed and versioned in real life), cached on disk.
const config = {
  version: 42,
  flags: {
    "ui.new-settings-page": { type: "boolean", enabled: true, default: false,
      rules: [{ when: { tenant: ["acme"] }, value: true }], rollout: { percent: 25, value: true } },
    "audio.noise-suppression": { type: "string", enabled: true, default: "standard",
      rules: [{ when: { model: ["room-bar"], minFirmware: "5.0.3" }, value: "aggressive" }] },
    "telemetry.verbose-logs": { type: "boolean", enabled: false, default: false }, // kill switch: off for everyone
  },
};

const firmwareAtLeast = (have, need) => {
  const [a, b] = [have, need].map((v) => v.split(".").map(Number));
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
  return true;
};
const matches = (when, ctx) =>
  Object.entries(when).every(([k, v]) => (k === "minFirmware" ? firmwareAtLeast(ctx.firmware, v) : v.includes(ctx[k])));
// Same device + same flag → same bucket, on every boot, with no server call.
const bucket = (flagKey, targetingKey) => createHash("sha256").update(`${flagKey}:${targetingKey}`).digest().readUInt32BE(0) % 100;

function evaluate(flagKey, defaultValue, ctx) {
  try {
    const flag = config.flags[flagKey];
    if (!flag) return { value: defaultValue, reason: "ERROR", errorCode: "FLAG_NOT_FOUND" };
    if (flag.type !== typeof defaultValue) return { value: defaultValue, reason: "ERROR", errorCode: "TYPE_MISMATCH" };
    if (!flag.enabled) return { value: flag.default, reason: "DISABLED" };
    for (const rule of flag.rules ?? []) if (matches(rule.when, ctx)) return { value: rule.value, reason: "TARGETING_MATCH" };
    if (flag.rollout) {
      if (!ctx.targetingKey) return { value: defaultValue, reason: "ERROR", errorCode: "TARGETING_KEY_MISSING" };
      if (bucket(flagKey, ctx.targetingKey) < flag.rollout.percent) return { value: flag.rollout.value, reason: "SPLIT" };
    }
    return { value: flag.default, reason: "DEFAULT" };
  } catch {
    return { value: defaultValue, reason: "ERROR", errorCode: "GENERAL" }; // a flag bug must never crash the device
  }
}

const devices = [
  { targetingKey: "SN000017", model: "room-bar", firmware: "5.0.3", tenant: "acme" },
  { targetingKey: "SN000018", model: "room-bar", firmware: "5.0.2", tenant: "globex" },
  { targetingKey: "SN000019", model: "codec", firmware: "5.0.3", tenant: "globex" },
  { model: "touch-panel", firmware: "5.0.3", tenant: "globex" }, // forgot to pass its serial
];
const show = (r) => `${JSON.stringify(r.value)} (${r.reason}${r.errorCode ? " " + r.errorCode : ""})`;
for (const d of devices) {
  console.log(`${d.targetingKey ?? "(no key)"} ${d.model} ${d.firmware} ${d.tenant}`);
  console.log(`  ui.new-settings-page     ${show(evaluate("ui.new-settings-page", false, d))}`);
  console.log(`  audio.noise-suppression  ${show(evaluate("audio.noise-suppression", "standard", d))}`);
}
const d0 = devices[1];
console.log(`telemetry.verbose-logs   ${show(evaluate("telemetry.verbose-logs", false, d0))}`);
console.log(`ui.dark-mode (typo'd)    ${show(evaluate("ui.dark-mode", false, d0))}`);
console.log(`audio.noise-suppression asked as boolean: ${show(evaluate("audio.noise-suppression", true, d0))}`);

// Raising the rollout from 10% to 25% only adds devices: nobody who had the feature loses it.
const serials = Array.from({ length: 10_000 }, (_, i) => `SN${String(i).padStart(6, "0")}`);
const inAt = (pct) => new Set(serials.filter((s) => bucket("ui.new-settings-page", s) < pct));
const [ten, quarter] = [inAt(10), inAt(25)];
console.log(`10%: ${ten.size} devices, 25%: ${quarter.size} devices, in 10% but not 25%: ${[...ten].filter((s) => !quarter.has(s)).length}`);
Result
SN000017 room-bar 5.0.3 acme
  ui.new-settings-page     true (TARGETING_MATCH)
  audio.noise-suppression  "aggressive" (TARGETING_MATCH)
SN000018 room-bar 5.0.2 globex
  ui.new-settings-page     false (DEFAULT)
  audio.noise-suppression  "standard" (DEFAULT)
SN000019 codec 5.0.3 globex
  ui.new-settings-page     true (SPLIT)
  audio.noise-suppression  "standard" (DEFAULT)
(no key) touch-panel 5.0.3 globex
  ui.new-settings-page     false (ERROR TARGETING_KEY_MISSING)
  audio.noise-suppression  "standard" (DEFAULT)
telemetry.verbose-logs   false (DISABLED)
ui.dark-mode (typo'd)    false (ERROR FLAG_NOT_FOUND)
audio.noise-suppression asked as boolean: true (ERROR TYPE_MISMATCH)
10%: 1059 devices, 25%: 2625 devices, in 10% but not 25%: 0
What’s happening
  1. evaluate checks in a fixed order: does the flag exist, is the caller asking for the right type, is the flag switched on, does a targeting rule match, is the device in the percentage rollout, otherwise the flag's default. Each exit returns a value and a reason; the whole body is wrapped in try, so even a bug in the evaluator returns the caller's default with GENERAL instead of crashing a room device.
  2. SN000017 belongs to tenant acme, so ui.new-settings-page hits the targeting rule (TARGETING_MATCH, a permissioning toggle for one customer). It's a room bar on 5.0.3, so audio.noise-suppression matches model and minFirmware and gets "aggressive". Firmware targeting matters on devices: a mode that relies on a driver fix in 5.0.3 must never turn on for 5.0.2, which is why SN000018 gets DEFAULT.
  3. The 25% rollout uses bucket(flagKey, serial): SHA-256 of "ui.new-settings-page:SN000019" gives bucket 1, under 25, so the codec gets the new page (SPLIT). SN000018 hashes to 97, so it doesn't. The bucket needs no server and no stored state, so the answer is the same on every boot. Including the flag key in the hash means each flag picks a different 25%.
  4. The touch panel's caller forgot the serial. Without a targeting key the rollout can't bucket it, so it returns the caller's default with TARGETING_KEY_MISSING. That's the value of reasons: in telemetry, a spike of TARGETING_KEY_MISSING from one model points straight at the bug, where "the feature isn't showing on touch panels" would take a day to trace.
  5. The kill switch telemetry.verbose-logs is enabled: false, so it returns its default with DISABLED for everyone. The misspelt ui.dark-mode returns the caller's default with FLAG_NOT_FOUND, and asking for a string flag as a boolean returns the caller's true with TYPE_MISMATCH: wrong calls degrade to the default, loudly in telemetry, quietly on the device.
  6. The last line checks the property that makes percentage rollouts safe: raising 10% to 25% adds devices (1,059 → 2,625 of 10,000) and removes none, because a device below 10 is also below 25. Turning a feature on and then off again for the same room would confuse its users and its admins.
AdvancedTelemetry: what the fleet tells you

Telemetry is what turns "we shipped 5.0.3" into "5.0.3 is crashing touch panels", the input the rollout gate in CI/CD for web UI, APIs and firmware depends on. OpenTelemetry (opentelemetry.io, "Signals", checked October 2026) lists traces, metrics, logs and baggage as supported signals, with profiles still in development; for a device fleet the useful split is:

KindDevice examplesNotes
Health metricsCrashes and ANRs per app, unexpected reboots, update outcome (success, rolled back, failed at step), uptime, memory, temperatureTagged with model, firmware, update path; these drive rollout gates
Quality metricsPer call: packet loss, jitter, round-trip time, frame rate, audio level, codecHistograms and percentiles, not every packet; see Media quality and diagnostics
EventsSettings changed (which key, not the value), peripheral connected or lost, flag evaluations with reasonLow volume, high value for debugging
LogsErrors and warnings, uploaded on demand or sampledNever by default in bulk: cost, bandwidth, privacy

Three device constraints shape the design. Devices go offline for hours (a building network outage, a weekend power-down), so telemetry is buffered on the device in bounded storage and sent in batches when the uplink returns. Cardinality has to be controlled: tag metrics with model and firmware, never with meeting ids or user names, or the time-series backend explodes and you've collected personal data. And privacy and consent are product requirements: what's collected, whether it can be switched off, and where it's stored is something enterprise customers' security teams will ask about, so it should be documented and enforced in code (an allowlist of fields), not left to each developer.

AdvancedTelemetry that survives being offline

The model shows the buffering rules. Each event gets a sequence number, the buffer is bounded (memory and flash are finite, and a device that fills its storage with telemetry is worse than one that loses some), the oldest events are dropped first and counted, and every batch carries the drop count, so the collector can tell "the device lost data while offline" from "the pipeline lost data".

telemetry-buffer.mjs
// Device telemetry that survives being offline: a bounded buffer, sequence numbers, batches and a drop counter.
function createTelemetry({ capacity, batchSize }) {
  let seq = 0, dropped = 0;
  const buffer = [];
  return {
    record(name, value, minute) {
      buffer.push({ seq: ++seq, name, value, minute });
      if (buffer.length > capacity) { buffer.shift(); dropped++; } // keep the newest; memory and flash are finite
    },
    // Called when the uplink is up. Each batch says how many events were dropped since the last batch.
    flush(send) {
      while (buffer.length) {
        const events = buffer.slice(0, batchSize);
        if (!send({ events, droppedSinceLast: dropped })) return; // send failed: keep everything, try later
        buffer.splice(0, events.length);
        dropped = 0;
      }
    },
    get size() { return buffer.length; },
  };
}

// The collector: detects gaps from sequence numbers and checks them against what the device says it dropped.
function createCollector() {
  let lastSeq = 0, received = 0, gaps = [], reportedDrops = 0;
  return {
    receive({ events, droppedSinceLast }) {
      reportedDrops += droppedSinceLast;
      for (const e of events) {
        if (e.seq !== lastSeq + 1) gaps.push(`${lastSeq + 1}–${e.seq - 1}`);
        lastSeq = e.seq; received++;
      }
      return true;
    },
    summary: () => `received ${received}, gaps ${gaps.join(", ") || "none"}, device reported ${reportedDrops} dropped`,
  };
}

const device = createTelemetry({ capacity: 120, batchSize: 50 });
const collector = createCollector();
let batches = 0;
// 6 hours, one sample a minute. The network is down from minute 60 to minute 240 (a building outage).
for (let minute = 1; minute <= 360; minute++) {
  device.record("call.audio.packet_loss_pct", (minute % 7) / 10, minute);
  const online = minute < 60 || minute >= 240;
  if (online && minute % 15 === 0) device.flush((batch) => (batches++, collector.receive(batch)));
  if (minute === 239) console.log(`minute 239 (still offline): ${device.size} events buffered`);
}
device.flush((batch) => (batches++, collector.receive(batch)));
console.log(`360 events recorded, ${batches} batches sent`);
console.log(collector.summary());
Result
minute 239 (still offline): 120 events buffered
360 events recorded, 14 batches sent
received 285, gaps 46–120, device reported 75 dropped
What’s happening
  1. The device records one sample a minute for 360 minutes and flushes every 15 minutes while online. Flushes at minutes 15, 30 and 45 send events 1–45 in three batches. The network is down from minute 60, and minutes 46–59 have no flush due before then, so from event 46 everything waits in the buffer.
  2. While offline the buffer fills to its capacity of 120 and then drops the oldest event for each new one, counting each drop. At minute 239 it holds exactly 120 events. Keeping the newest is usually right for health data: what the device is doing now matters more than what it did three hours ago.
  3. At minute 240 the network is back. That minute's event is recorded first, which drops one more (75 in total, events 46–120), then the flush sends the 120 buffered events (121–240) in batches of 50, 50 and 20, each carrying droppedSinceLast. From then on, a batch of 15 events goes every 15 minutes: 8 more batches, 14 in all.
  4. The collector checks sequence numbers: after event 45 the next one it sees is 121, so it records a gap 46–120, and the device's own count (75 dropped) explains it exactly. If the gap were bigger than the reported drops, the loss happened in transit or in the pipeline, which is a different bug with a different owner.
  5. If send fails, flush returns without removing anything, so events are retried later rather than lost. In production you'd add back-off with jitter for the retries, so ten thousand devices coming back after a regional outage don't all flush in the same second.
AdvancedField diagnostics: understanding one device

When one customer's device misbehaves, support needs everything about that device at that moment, without someone flying to the site. The pieces:

  • A diagnostics bundle on demand. An admin (in the device's web UI or the cloud console) asks the device to collect logs, current configuration, recent crashes, call statistics and system state into one archive. Android's own version of this is the bug report: adb bugreport produces a zip named bugreport-BUILD_ID-DATE.zip containing "diagnostic output for system services (dumpsys), error logs (dumpstate), and system message logs (logcat)" (Android Studio docs, "Capture and read bug reports", checked October 2026). A device's own bundle usually wraps similar sources plus its application logs. Collecting can take a while, so the API is a long-running operation: POST /api/v1/diagnostics/bundles returns 202 and an operation to poll, then a download link.
  • Temporary verbose logging. Raise the log level for one subsystem on one device for a limited time, automatically reverting. That's a flag with targeting by serial and an expiry, and the expiry matters: verbose logging left on fills storage and slows the device.
  • Remote checks. A connectivity test from the device (DNS, the provisioning server, the call service), a short packet capture with a size and time limit, a test call. Each is powerful, so each needs an admin role, an audit-log entry (see Least privilege and audit logs), and rate limits.
  • Correlation. Every request from the web UI or the cloud carries an id that the device writes into its logs, so a support engineer can follow "the admin clicked Save at 09:15" through the API, the settings service and the platform. See Debugging across the stack.

Poly's public documentation couldn't be checked in enough detail for this page to describe its diagnostics features or endpoints, so the pattern above is generic, not a description of Poly's.

AdvancedRedacting a diagnostics bundle on the device

A bundle from a conference room is full of things that must not leave it: SIP and admin passwords in configuration, tokens in URLs, people's email addresses, and meeting titles, which can be confidential in themselves. Redaction belongs on the device, before upload, so the support system never holds the secrets at all, and the bundle should say what was redacted so support knows what's missing.

diag-redact.mjs
// Building a field-diagnostics bundle: collect, redact secrets and personal data on the device, then describe it.
import { createHmac } from "node:crypto";

const files = {
  "logcat.txt": [
    "10-27 09:14:02.118 I CallService: joined meeting 'Q3 layoffs planning' id=8841-22",
    "10-27 09:14:02.530 W SipStack: REGISTER failed 401, retrying user=room4 password=Hunter2!",
    "10-27 09:15:10.004 E Provisioning: GET https://prov.example.com/cfg?token=eyJhbGciOiJIUzI1NiJ9.abc.def -> 500",
    "10-27 09:15:10.010 D Http: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyb29tNCJ9.c2ln",
    "10-27 09:15:11.220 I WebServer: admin login from 10.20.30.41 user=priya.k@example.com",
  ],
  "settings.json": [JSON.stringify({ name: "Huddle 4", volume: 50, sipPassword: "s3cr3t", adminPin: "4821" })],
};

const BUNDLE_KEY = "random per bundle; fixed here so the output is stable";
const RULES = [
  ["secret", /(password|pin|secret|token)=([^\s&]+)/gi, (m, k) => `${k}=[REDACTED]`],
  ["secret", /"(\w*(?:password|pin|secret|token)\w*)":"[^"]*"/gi, (m, k) => `"${k}":"[REDACTED]"`],
  ["bearer", /eyJ[\w-]+\.[\w-]+\.[\w-]+/g, () => "[JWT]"],
  ["email", /[\w.+-]+@[\w-]+\.[\w.]+/g, () => "[EMAIL]"],
  ["meeting title", /meeting '[^']*'/g, () => "meeting '[TITLE]'"],
  // Pseudonymise client IPs with a keyed hash: "the same client" stays visible across lines, the address doesn't.
  ["ip", /\b(\d{1,3}\.){3}\d{1,3}\b/g, (ip) => `ip#${createHmac("sha256", BUNDLE_KEY).update(ip).digest("hex").slice(0, 8)}`],
];

const counts = {};
const bundle = {};
for (const [name, lines] of Object.entries(files)) {
  bundle[name] = lines.map((line) =>
    RULES.reduce((text, [label, re, fix]) => text.replace(re, (...m) => { counts[label] = (counts[label] ?? 0) + 1; return fix(...m.slice(0, -2)); }), line));
}
for (const [name, lines] of Object.entries(bundle)) { console.log(`--- ${name}`); for (const l of lines) console.log(l); }
const manifest = { device: "SN000017", firmware: "5.0.3", files: Object.keys(bundle), redactions: counts };
console.log(`--- manifest.json\n${JSON.stringify(manifest)}`);
Result
--- logcat.txt
10-27 09:14:02.118 I CallService: joined meeting '[TITLE]' id=8841-22
10-27 09:14:02.530 W SipStack: REGISTER failed 401, retrying user=room4 password=[REDACTED]
10-27 09:15:10.004 E Provisioning: GET https://prov.example.com/cfg?token=[REDACTED] -> 500
10-27 09:15:10.010 D Http: Authorization: Bearer [JWT]
10-27 09:15:11.220 I WebServer: admin login from ip#1a6b16d6 user=[EMAIL]
--- settings.json
{"name":"Huddle 4","volume":50,"sipPassword":"[REDACTED]","adminPin":"[REDACTED]"}
--- manifest.json
{"device":"SN000017","firmware":"5.0.3","files":["logcat.txt","settings.json"],"redactions":{"meeting title":1,"secret":4,"bearer":1,"email":1,"ip":1}}
What’s happening
  1. Each line runs through every rule in order with reduce; each match increments a counter for its label and is replaced. The rules are ordered so the most specific secrets go first: token=… in the provisioning URL is caught as a secret before the generic JWT rule could see it, which is why that line says token=[REDACTED] and the Authorization: Bearer line, which has no token=, is caught by the bearer rule.
  2. Configuration is redacted by key name: any JSON key containing password, pin, secret or token keeps its key but loses its value, so support can still see that a SIP password is set, which is often the question, without seeing it. Four secrets in total: the SIP password in the log, the URL token, sipPassword and adminPin.
  3. The meeting title is replaced but the meeting id is kept: the id lets support correlate with the call service's own records, while the title ("Q3 layoffs planning") is exactly the kind of content a customer would be appalled to find in a vendor's support ticket.
  4. Client IP addresses are pseudonymised with an HMAC keyed per bundle rather than deleted: ip#1a6b16d6 still lets support see that the same client appears in several lines. A plain unkeyed SHA-256 would be a mistake: there are only about 4 billion IPv4 addresses, so anyone can hash them all and reverse it. The key is fixed here only so the output is stable.
  5. The manifest records what was collected and how many of each kind were redacted. Regex redaction is a denylist, and denylists miss things (a password logged under a new key name, a token format nobody listed), so the stronger fix is upstream: structured logging where sensitive fields are marked at the call site and never written in clear, with redaction as the safety net.
First boot with no network

A brand-new device boots in a customer's room with no network yet. Its firmware has the flag config cached from the factory (version 7), but the cloud now has version 42, where ui.new-settings-page is on for 25% of devices. What does the device show, what reason does it record, and what happens when the network comes up?

Show answer

It evaluates from what it has: the factory config (or, if a flag is missing from that, the compiled-in default), records the result with its reason, and switches to version 42's answer once that config is downloaded and verified.

What’s happening
  1. Evaluation never waits for the network: the device loads its cached config, version 7. If version 7 defines ui.new-settings-page (say as false for everyone), the device shows the old page and records DEFAULT with config version 7 alongside it.
  2. If version 7 predates the flag entirely, the lookup fails and the code's own default (false) is used with ERROR FLAG_NOT_FOUND. That isn't a crash and it isn't wrong, but recording the config version with the reason lets telemetry tell "old config" from "typo in the flag name".
  3. When the network comes up, the device downloads version 42, verifies it, caches it, and re-evaluates. Its serial's bucket decides whether it's in the 25%. If it is, the UI changes; a well-behaved UI applies such changes at a natural moment (next page load) rather than mid-session.
  4. The design rule behind all of this: flags may change what the device does, but the network may never be on the critical path for the device to work at all.

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.