@tomorrowos/sdk 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { TomorrowOS } from "./tomorrowos.js";
2
2
  export type { DeviceListItem, ListenOptions, TomorrowOSBrand, TomorrowOSOptions } from "./tomorrowos.js";
3
- export type { PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./store/types.js";
3
+ export type { DeviceRegistryRecord, PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./store/types.js";
4
+ export { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH } from "./pairing-code.js";
4
5
  export { MemoryStore } from "./store/memory-store.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EACV,cAAc,EACd,aAAa,EACb,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EACV,cAAc,EACd,aAAa,EACb,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,yBAAyB,EACzB,wBAAwB,EACxB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { TomorrowOS } from "./tomorrowos.js";
2
+ export { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH } from "./pairing-code.js";
2
3
  export { MemoryStore } from "./store/memory-store.js";
@@ -0,0 +1,8 @@
1
+ /** 0-9 and A-Z — 36 characters per digit position. */
2
+ export declare const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
3
+ export declare const PAIRING_CODE_LENGTH = 6;
4
+ export declare function normalizePairingCode(raw: string): string;
5
+ export declare function isValidPairingCodeFormat(code: string): boolean;
6
+ /** Random 6-character code; each position is digit or uppercase letter. */
7
+ export declare function generateRandomPairingCode(): string;
8
+ //# sourceMappingURL=pairing-code.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-code.d.ts","sourceRoot":"","sources":["../src/pairing-code.ts"],"names":[],"mappings":"AAEA,sDAAsD;AACtD,eAAO,MAAM,qBAAqB,yCAAyC,CAAC;AAE5E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKxD;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAK9D;AAED,2EAA2E;AAC3E,wBAAgB,yBAAyB,IAAI,MAAM,CAOlD"}
@@ -0,0 +1,23 @@
1
+ import { randomBytes } from "crypto";
2
+ /** 0-9 and A-Z — 36 characters per digit position. */
3
+ export const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
4
+ export const PAIRING_CODE_LENGTH = 6;
5
+ export function normalizePairingCode(raw) {
6
+ return String(raw || "")
7
+ .trim()
8
+ .toUpperCase()
9
+ .replace(/[^0-9A-Z]/g, "");
10
+ }
11
+ export function isValidPairingCodeFormat(code) {
12
+ return (code.length === PAIRING_CODE_LENGTH &&
13
+ [...code].every((ch) => PAIRING_CODE_ALPHABET.includes(ch)));
14
+ }
15
+ /** Random 6-character code; each position is digit or uppercase letter. */
16
+ export function generateRandomPairingCode() {
17
+ const bytes = randomBytes(PAIRING_CODE_LENGTH);
18
+ let out = "";
19
+ for (let i = 0; i < PAIRING_CODE_LENGTH; i += 1) {
20
+ out += PAIRING_CODE_ALPHABET[bytes[i] % PAIRING_CODE_ALPHABET.length];
21
+ }
22
+ return out;
23
+ }
@@ -1,14 +1,22 @@
1
- import type { PairedDeviceEntry, PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./types.js";
1
+ import type { DeviceRegistryRecord, PairedDeviceEntry, PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./types.js";
2
2
  /**
3
3
  * Default store for development / single-node demos.
4
4
  * Data is lost on process restart — not for multi-instance production.
5
5
  */
6
6
  export declare class MemoryStore implements TomorrowOSStore {
7
7
  private readonly pendingCodes;
8
+ private readonly deviceRegistry;
9
+ private readonly codeToDeviceId;
8
10
  private readonly pairedDevices;
9
11
  setPendingCode(code: string, record: PendingCodeRecord): Promise<void>;
10
12
  getPendingCode(code: string): Promise<PendingCodeRecord | undefined>;
11
13
  deletePendingCode(code: string): Promise<void>;
14
+ getDeviceRegistry(deviceId: string): Promise<DeviceRegistryRecord | undefined>;
15
+ setDeviceRegistry(deviceId: string, record: DeviceRegistryRecord): Promise<void>;
16
+ getDeviceRegistryByCode(code: string): Promise<{
17
+ deviceId: string;
18
+ record: DeviceRegistryRecord;
19
+ } | undefined>;
12
20
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
13
21
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
14
22
  deletePairedDevice(deviceId: string): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,WAAY,YAAW,eAAe;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAwC;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyC;IAEjE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIpE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,IAAI,CAAC;IAIV,eAAe,CACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAIpC,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;CAMxD"}
1
+ {"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,WAAY,YAAW,eAAe;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAwC;IACrE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2C;IAC1E,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyC;IAEjE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIpE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,iBAAiB,CACrB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC;IAItC,iBAAiB,CACrB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC;IASV,uBAAuB,CAC3B,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,oBAAoB,CAAA;KAAE,GAAG,SAAS,CAAC;IAQpE,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,IAAI,CAAC;IAIV,eAAe,CACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAIpC,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;CAMxD"}
@@ -4,6 +4,8 @@
4
4
  */
5
5
  export class MemoryStore {
6
6
  pendingCodes = new Map();
7
+ deviceRegistry = new Map();
8
+ codeToDeviceId = new Map();
7
9
  pairedDevices = new Map();
8
10
  async setPendingCode(code, record) {
9
11
  this.pendingCodes.set(code, record);
@@ -14,6 +16,26 @@ export class MemoryStore {
14
16
  async deletePendingCode(code) {
15
17
  this.pendingCodes.delete(code);
16
18
  }
19
+ async getDeviceRegistry(deviceId) {
20
+ return this.deviceRegistry.get(deviceId);
21
+ }
22
+ async setDeviceRegistry(deviceId, record) {
23
+ const existing = this.deviceRegistry.get(deviceId);
24
+ if (existing?.permanentPairingCode) {
25
+ this.codeToDeviceId.delete(existing.permanentPairingCode);
26
+ }
27
+ this.deviceRegistry.set(deviceId, record);
28
+ this.codeToDeviceId.set(record.permanentPairingCode, deviceId);
29
+ }
30
+ async getDeviceRegistryByCode(code) {
31
+ const deviceId = this.codeToDeviceId.get(code);
32
+ if (!deviceId)
33
+ return undefined;
34
+ const record = this.deviceRegistry.get(deviceId);
35
+ if (!record)
36
+ return undefined;
37
+ return { deviceId, record };
38
+ }
17
39
  async setPairedDevice(deviceId, record) {
18
40
  this.pairedDevices.set(deviceId, record);
19
41
  }
@@ -6,6 +6,14 @@ export interface PendingCodeRecord {
6
6
  deviceId: string;
7
7
  createdAt: number;
8
8
  }
9
+ /** Permanent pairing code bound to a device (serial) on first hello — never rotated. */
10
+ export interface DeviceRegistryRecord {
11
+ permanentPairingCode: string;
12
+ codeCreatedAt: number;
13
+ serialNumber?: string;
14
+ firstSeenAt?: number;
15
+ lastHelloAt?: number;
16
+ }
9
17
  export interface PairedDeviceRecord {
10
18
  pairingToken: string;
11
19
  pairedAt: string;
@@ -29,6 +37,12 @@ export interface TomorrowOSStore {
29
37
  setPendingCode(code: string, record: PendingCodeRecord): Promise<void>;
30
38
  getPendingCode(code: string): Promise<PendingCodeRecord | undefined>;
31
39
  deletePendingCode(code: string): Promise<void>;
40
+ getDeviceRegistry(deviceId: string): Promise<DeviceRegistryRecord | undefined>;
41
+ setDeviceRegistry(deviceId: string, record: DeviceRegistryRecord): Promise<void>;
42
+ getDeviceRegistryByCode(code: string): Promise<{
43
+ deviceId: string;
44
+ record: DeviceRegistryRecord;
45
+ } | undefined>;
32
46
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
33
47
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
34
48
  deletePairedDevice(deviceId: string): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/store/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACrE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC3E,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;CACnD"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/store/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,MAAM,WAAW,oBAAoB;IACnC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACrE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAC;IAC/E,iBAAiB,CACf,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB,uBAAuB,CACrB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,oBAAoB,CAAA;KAAE,GAAG,SAAS,CAAC,CAAC;IAC3E,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC3E,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;CACnD"}
@@ -29,10 +29,13 @@ export interface ListenOptions {
29
29
  }
30
30
  export interface DeviceListItem {
31
31
  deviceId: string;
32
+ /** Permanent 6-character alphanumeric pairing code (same after unpair). */
33
+ pairingCode: string | null;
32
34
  connected: boolean;
33
35
  deviceName: string | null;
34
36
  platform: string | null;
35
37
  system: string | null;
38
+ serialNumber: string | null;
36
39
  pairedAt: string;
37
40
  lastBootAt: string | null;
38
41
  lastOnlineAt: string | null;
@@ -53,7 +56,7 @@ export declare class TomorrowOS extends EventEmitter {
53
56
  private staticRoot;
54
57
  private staticIndexFile;
55
58
  constructor(options: TomorrowOSOptions);
56
- /** Verify a 6-digit pairing code (same as POST /pairing/verify). */
59
+ /** Verify a 6-character alphanumeric pairing code (POST /pairing/verify). */
57
60
  pairingVerify(code: string): Promise<{
58
61
  deviceId: string;
59
62
  }>;
@@ -75,6 +78,7 @@ export declare class TomorrowOS extends EventEmitter {
75
78
  listDevices(): Promise<DeviceListItem[]>;
76
79
  private isDeviceConnected;
77
80
  private captureHelloMeta;
81
+ private getOrCreatePermanentPairingCode;
78
82
  private mergePairedRecord;
79
83
  private touchPairedOnline;
80
84
  private touchPairedOffline;
@@ -1 +1 @@
1
- {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,7 @@ import fs from "fs/promises";
4
4
  import http from "http";
5
5
  import path from "path";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
+ import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode } from "./pairing-code.js";
7
8
  import { MemoryStore } from "./store/memory-store.js";
8
9
  function formatDurationMs(ms) {
9
10
  if (!Number.isFinite(ms) || ms < 0)
@@ -23,8 +24,16 @@ function formatDurationMs(ms) {
23
24
  parts.push(`${seconds}s`);
24
25
  return parts.join(" ");
25
26
  }
26
- function createSixDigitCode() {
27
- return Math.floor(100000 + Math.random() * 900000).toString();
27
+ function resolveHelloDeviceId(msg) {
28
+ const serial = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
29
+ ? msg.serialNumber.trim()
30
+ : null;
31
+ if (serial)
32
+ return serial;
33
+ const deviceId = typeof msg.deviceId === "string" && msg.deviceId.trim()
34
+ ? msg.deviceId.trim()
35
+ : null;
36
+ return deviceId;
28
37
  }
29
38
  const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
30
39
  async function readJsonBody(req) {
@@ -82,41 +91,55 @@ export class TomorrowOS extends EventEmitter {
82
91
  this.brand = options.brand;
83
92
  this.store = options.store ?? new MemoryStore();
84
93
  }
85
- /** Verify a 6-digit pairing code (same as POST /pairing/verify). */
94
+ /** Verify a 6-character alphanumeric pairing code (POST /pairing/verify). */
86
95
  async pairingVerify(code) {
87
- const record = await this.store.getPendingCode(code);
88
- if (!record) {
89
- const err = new Error("Invalid or expired code");
96
+ const normalized = normalizePairingCode(code);
97
+ if (!isValidPairingCodeFormat(normalized)) {
98
+ const err = new Error("Invalid pairing code format");
99
+ err.code = "PAIRING_INVALID";
100
+ throw err;
101
+ }
102
+ let deviceId;
103
+ const registry = await this.store.getDeviceRegistryByCode(normalized);
104
+ if (registry) {
105
+ deviceId = registry.deviceId;
106
+ }
107
+ else {
108
+ const pending = await this.store.getPendingCode(normalized);
109
+ deviceId = pending?.deviceId;
110
+ }
111
+ if (!deviceId) {
112
+ const err = new Error("Invalid or unknown pairing code");
90
113
  err.code = "PAIRING_INVALID";
91
114
  throw err;
92
115
  }
93
116
  const pairingToken = randomBytes(32).toString("hex");
94
117
  const pairedAt = new Date().toISOString();
95
- const meta = this.pendingDeviceMeta.get(record.deviceId);
118
+ const meta = this.pendingDeviceMeta.get(deviceId);
96
119
  const now = pairedAt;
97
- await this.store.setPairedDevice(record.deviceId, {
120
+ await this.store.setPairedDevice(deviceId, {
98
121
  pairingToken,
99
122
  pairedAt,
100
123
  deviceName: meta?.deviceName,
101
124
  platform: meta?.platform,
102
125
  system: meta?.system,
103
126
  lastBootAt: meta?.bootedAt ?? now,
104
- lastOnlineAt: this.isDeviceConnected(record.deviceId) ? now : undefined,
105
- lastOfflineAt: this.isDeviceConnected(record.deviceId) ? undefined : now
127
+ lastOnlineAt: this.isDeviceConnected(deviceId) ? now : undefined,
128
+ lastOfflineAt: this.isDeviceConnected(deviceId) ? undefined : now
106
129
  });
107
- await this.store.deletePendingCode(code);
108
- const ws = this.devices.get(record.deviceId);
130
+ await this.store.deletePendingCode(normalized);
131
+ const ws = this.devices.get(deviceId);
109
132
  if (ws && ws.readyState === WebSocket.OPEN) {
110
133
  ws.send(JSON.stringify({
111
134
  type: "pairing.verified",
112
135
  method: "tomorrowos.pairing.verify",
113
- deviceId: record.deviceId,
136
+ deviceId,
114
137
  pairingToken
115
138
  }));
116
- void this.refreshPairedDeviceInfo(record.deviceId);
139
+ void this.refreshPairedDeviceInfo(deviceId);
117
140
  }
118
- this.emit("device.paired", { deviceId: record.deviceId });
119
- return { deviceId: record.deviceId };
141
+ this.emit("device.paired", { deviceId });
142
+ return { deviceId };
120
143
  }
121
144
  /** Remove pairing for a device and notify it over WebSocket if connected. */
122
145
  async pairingUnpair(deviceId) {
@@ -149,8 +172,9 @@ export class TomorrowOS extends EventEmitter {
149
172
  async listDevices() {
150
173
  const entries = await this.store.listPairedDevices();
151
174
  const now = Date.now();
152
- return entries.map(({ deviceId, record }) => {
175
+ return Promise.all(entries.map(async ({ deviceId, record }) => {
153
176
  const connected = this.isDeviceConnected(deviceId);
177
+ const reg = await this.store.getDeviceRegistry(deviceId);
154
178
  let screenOnlineActive = false;
155
179
  let screenOnlineLabel = "Not active";
156
180
  if (connected && record.lastBootAt) {
@@ -163,10 +187,12 @@ export class TomorrowOS extends EventEmitter {
163
187
  const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
164
188
  return {
165
189
  deviceId,
190
+ pairingCode: reg?.permanentPairingCode ?? null,
166
191
  connected,
167
192
  deviceName: record.deviceName ?? null,
168
193
  platform: record.platform ?? null,
169
194
  system: record.system ?? null,
195
+ serialNumber: reg?.serialNumber ?? deviceId,
170
196
  pairedAt: record.pairedAt,
171
197
  lastBootAt: record.lastBootAt ?? null,
172
198
  lastOnlineAt: record.lastOnlineAt ?? null,
@@ -176,7 +202,7 @@ export class TomorrowOS extends EventEmitter {
176
202
  screenOnlineLabel,
177
203
  screenOnlineSince
178
204
  };
179
- });
205
+ }));
180
206
  }
181
207
  isDeviceConnected(deviceId) {
182
208
  const ws = this.devices.get(deviceId);
@@ -188,9 +214,37 @@ export class TomorrowOS extends EventEmitter {
188
214
  deviceName: typeof msg.deviceName === "string" ? msg.deviceName : undefined,
189
215
  system: typeof msg.system === "string" ? msg.system : undefined,
190
216
  bootedAt: typeof msg.bootedAt === "string" ? msg.bootedAt : undefined,
191
- playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined
217
+ playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined,
218
+ serialNumber: typeof msg.serialNumber === "string" ? msg.serialNumber : deviceId
192
219
  });
193
220
  }
221
+ async getOrCreatePermanentPairingCode(deviceId, serialNumber) {
222
+ const existing = await this.store.getDeviceRegistry(deviceId);
223
+ if (existing?.permanentPairingCode) {
224
+ await this.store.setDeviceRegistry(deviceId, {
225
+ ...existing,
226
+ serialNumber: serialNumber ?? existing.serialNumber ?? deviceId,
227
+ lastHelloAt: Date.now()
228
+ });
229
+ return existing.permanentPairingCode;
230
+ }
231
+ for (let attempt = 0; attempt < 32; attempt += 1) {
232
+ const code = generateRandomPairingCode();
233
+ const collision = await this.store.getDeviceRegistryByCode(code);
234
+ if (collision && collision.deviceId !== deviceId)
235
+ continue;
236
+ const now = Date.now();
237
+ await this.store.setDeviceRegistry(deviceId, {
238
+ permanentPairingCode: code,
239
+ codeCreatedAt: now,
240
+ serialNumber: serialNumber ?? deviceId,
241
+ firstSeenAt: now,
242
+ lastHelloAt: now
243
+ });
244
+ return code;
245
+ }
246
+ throw new Error("Failed to allocate unique pairing code");
247
+ }
194
248
  async mergePairedRecord(deviceId, patch) {
195
249
  const existing = await this.store.getPairedDevice(deviceId);
196
250
  if (!existing)
@@ -593,25 +647,34 @@ export class TomorrowOS extends EventEmitter {
593
647
  }
594
648
  const type = msg.type;
595
649
  if (type === "device.hello") {
596
- const deviceId = typeof msg.deviceId === "string" && msg.deviceId
597
- ? msg.deviceId
598
- : randomUUID();
599
- const code = createSixDigitCode();
600
- this.captureHelloMeta(deviceId, msg);
601
- this.devices.set(deviceId, ws);
602
- void this.store.setPendingCode(code, {
603
- deviceId,
604
- createdAt: Date.now()
605
- });
606
- ws.deviceId = deviceId;
607
- ws.send(JSON.stringify({
608
- type: "pairing.code",
609
- method: "tomorrowos.pairing.createCode",
610
- code,
611
- deviceId
612
- }));
613
- this.sendBrandSnapshot(ws);
614
- this.emit("device.online", { deviceId });
650
+ const deviceId = resolveHelloDeviceId(msg) ?? randomUUID();
651
+ const serialNumber = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
652
+ ? msg.serialNumber.trim()
653
+ : deviceId;
654
+ void (async () => {
655
+ try {
656
+ const code = await this.getOrCreatePermanentPairingCode(deviceId, serialNumber);
657
+ this.captureHelloMeta(deviceId, msg);
658
+ this.devices.set(deviceId, ws);
659
+ void this.store.setPendingCode(code, {
660
+ deviceId,
661
+ createdAt: Date.now()
662
+ });
663
+ ws.deviceId = deviceId;
664
+ ws.send(JSON.stringify({
665
+ type: "pairing.code",
666
+ method: "tomorrowos.pairing.createCode",
667
+ code,
668
+ deviceId,
669
+ serialNumber
670
+ }));
671
+ this.sendBrandSnapshot(ws);
672
+ this.emit("device.online", { deviceId });
673
+ }
674
+ catch (err) {
675
+ console.error("[TomorrowOS] device.hello failed:", err);
676
+ }
677
+ })();
615
678
  return;
616
679
  }
617
680
  if (type === "device.resume") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "TomorrowOS CMS server SDK — WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,30 +1,30 @@
1
- {
2
- "policy": {
3
- "playlists": [
4
- {
5
- "id": "weekday-promo",
6
- "name": "Weekday promo",
7
- "schedule": {
8
- "startDate": "2026-05-01",
9
- "endDate": "2026-05-31",
10
- "daysOfWeek": [1, 2, 3, 4, 5],
11
- "start": "09:00",
12
- "end": "17:00"
13
- },
14
- "items": [
15
- {
16
- "url": "https://example.com/hero.jpg",
17
- "type": "image",
18
- "durationMs": 10000
19
- },
20
- {
21
- "url": "https://example.com/spot.mp4",
22
- "type": "video",
23
- "durationMs": 30000
24
- }
25
- ]
26
- }
27
- ],
28
- "fallback": { "type": "brand" }
29
- }
30
- }
1
+ {
2
+ "policy": {
3
+ "playlists": [
4
+ {
5
+ "id": "weekday-promo",
6
+ "name": "Weekday promo",
7
+ "schedule": {
8
+ "startDate": "2026-05-01",
9
+ "endDate": "2026-05-31",
10
+ "daysOfWeek": [1, 2, 3, 4, 5],
11
+ "start": "09:00",
12
+ "end": "17:00"
13
+ },
14
+ "items": [
15
+ {
16
+ "url": "https://example.com/hero.jpg",
17
+ "type": "image",
18
+ "durationMs": 10000
19
+ },
20
+ {
21
+ "url": "https://example.com/spot.mp4",
22
+ "type": "video",
23
+ "durationMs": 30000
24
+ }
25
+ ]
26
+ }
27
+ ],
28
+ "fallback": { "type": "brand" }
29
+ }
30
+ }
@@ -16,9 +16,9 @@
16
16
  <main class="panel-main">
17
17
  <section class="card">
18
18
  <h2>Pair device</h2>
19
- <p class="hint">Enter the 6-digit code shown on a screen. Each verified device gets its own card below.</p>
19
+ <p class="hint">Enter the 6-character code on the screen (A–Z or 0–9). Each device keeps the same code after unpair.</p>
20
20
  <div class="row">
21
- <input id="code" maxlength="6" placeholder="6-digit code" />
21
+ <input id="code" maxlength="6" placeholder="e.g. A3K9Z1" autocapitalize="characters" />
22
22
  <button type="button" onclick="verify()">Verify</button>
23
23
  </div>
24
24
  </section>
@@ -486,7 +486,15 @@ function buildPolicyPayload() {
486
486
  }
487
487
 
488
488
  async function verify() {
489
- const code = document.getElementById("code").value;
489
+ const code = String(document.getElementById("code").value || "")
490
+ .trim()
491
+ .toUpperCase()
492
+ .replace(/[^0-9A-Z]/g, "");
493
+
494
+ if (code.length !== 6) {
495
+ showResult({ status: "failed", error: "Enter the 6-character code from the screen." });
496
+ return;
497
+ }
490
498
 
491
499
  const res = await fetch("/pairing/verify", {
492
500
  method: "POST",
@@ -507,7 +515,7 @@ async function verify() {
507
515
  async function unpairDevice(deviceId) {
508
516
  if (
509
517
  !confirm(
510
- "Unpair this device? Its card will be removed and the screen will show a new pairing code."
518
+ "Unpair this device? Its card will be removed; the screen keeps the same permanent pairing code."
511
519
  )
512
520
  ) {
513
521
  return;
@@ -1,329 +1,329 @@
1
- * {
2
- box-sizing: border-box;
3
- }
4
-
5
- body {
6
- margin: 0;
7
- font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
8
- background: #f4f3f0;
9
- color: #0a0908;
10
- }
11
-
12
- .app-header {
13
- padding: 1rem 1.5rem;
14
- background: #0a0908;
15
- color: #fafaf9;
16
- }
17
-
18
- .app-header h1 {
19
- margin: 0;
20
- font-size: 1.25rem;
21
- font-weight: 600;
22
- }
23
-
24
- .app-header p {
25
- margin: 0.35rem 0 0;
26
- font-size: 0.875rem;
27
- opacity: 0.8;
28
- }
29
-
30
- .layout {
31
- display: flex;
32
- gap: 0;
33
- min-height: calc(100vh - 4.5rem);
34
- }
35
-
36
- .panel-main {
37
- flex: 1;
38
- padding: 1.25rem 1.5rem 2rem;
39
- overflow: auto;
40
- }
41
-
42
- .panel-playlist {
43
- width: 320px;
44
- flex-shrink: 0;
45
- background: #fff;
46
- border-left: 1px solid #e4e1dc;
47
- display: flex;
48
- flex-direction: column;
49
- }
50
-
51
- .card {
52
- background: #fff;
53
- border: 1px solid #e4e1dc;
54
- border-radius: 10px;
55
- padding: 1rem 1.1rem;
56
- margin-bottom: 1rem;
57
- }
58
-
59
- .card h2 {
60
- margin: 0 0 0.75rem;
61
- font-size: 0.95rem;
62
- font-weight: 600;
63
- }
64
-
65
- .hint {
66
- margin: 0 0 0.75rem;
67
- font-size: 0.8rem;
68
- color: #666;
69
- }
70
-
71
- .devices-grid {
72
- display: grid;
73
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
74
- gap: 0.85rem;
75
- }
76
-
77
- .devices-empty {
78
- margin: 0;
79
- color: #666;
80
- font-size: 0.875rem;
81
- grid-column: 1 / -1;
82
- }
83
-
84
- .device-card {
85
- border: 1px solid #e4e1dc;
86
- border-radius: 8px;
87
- padding: 0.85rem 0.95rem;
88
- background: #fafaf9;
89
- }
90
-
91
- .device-card-header {
92
- display: flex;
93
- align-items: center;
94
- gap: 0.5rem;
95
- margin-bottom: 0.65rem;
96
- }
97
-
98
- .device-card-title {
99
- margin: 0;
100
- font-size: 0.95rem;
101
- font-weight: 600;
102
- line-height: 1.2;
103
- }
104
-
105
- .status-led {
106
- width: 11px;
107
- height: 11px;
108
- border-radius: 50%;
109
- flex-shrink: 0;
110
- box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
111
- }
112
-
113
- .status-led--online {
114
- background: #12b76a;
115
- box-shadow: 0 0 8px rgba(18, 183, 106, 0.55);
116
- }
117
-
118
- .status-led--offline {
119
- background: #f04438;
120
- box-shadow: 0 0 8px rgba(240, 68, 56, 0.45);
121
- }
122
-
123
- .device-meta {
124
- display: grid;
125
- gap: 0.35rem;
126
- font-size: 0.78rem;
127
- color: #444;
128
- margin-bottom: 0.75rem;
129
- }
130
-
131
- .device-meta dt {
132
- font-weight: 600;
133
- color: #666;
134
- display: inline;
135
- }
136
-
137
- .device-meta dt::after {
138
- content: ": ";
139
- }
140
-
141
- .device-meta dd {
142
- display: inline;
143
- margin: 0;
144
- word-break: break-all;
145
- }
146
-
147
- .device-meta-row {
148
- display: block;
149
- }
150
-
151
- .device-card-actions {
152
- display: flex;
153
- flex-wrap: wrap;
154
- gap: 0.4rem;
155
- }
156
-
157
- .device-card-actions button {
158
- font-size: 0.78rem;
159
- padding: 0.35rem 0.6rem;
160
- }
161
-
162
- .row {
163
- display: flex;
164
- flex-wrap: wrap;
165
- gap: 0.5rem;
166
- align-items: center;
167
- }
168
-
169
- input[type="text"],
170
- input[type="date"],
171
- input[type="time"],
172
- select {
173
- font: inherit;
174
- padding: 0.45rem 0.6rem;
175
- border: 1px solid #d6d2cb;
176
- border-radius: 6px;
177
- background: #fff;
178
- }
179
-
180
- button {
181
- font: inherit;
182
- padding: 0.45rem 0.85rem;
183
- border-radius: 6px;
184
- border: 1px solid #d6d2cb;
185
- background: #fff;
186
- cursor: pointer;
187
- }
188
-
189
- button:hover {
190
- background: #f5f3ef;
191
- }
192
-
193
- button.primary {
194
- background: #ff8a3d;
195
- border-color: #e67320;
196
- color: #0a0908;
197
- font-weight: 600;
198
- }
199
-
200
- button.primary:hover {
201
- background: #ff9a57;
202
- }
203
-
204
- button.danger {
205
- color: #b42318;
206
- border-color: #fecdca;
207
- }
208
-
209
- .schedule-grid {
210
- display: grid;
211
- grid-template-columns: 1fr 1fr;
212
- gap: 0.65rem 1rem;
213
- }
214
-
215
- .schedule-grid label {
216
- display: flex;
217
- flex-direction: column;
218
- gap: 0.25rem;
219
- font-size: 0.8rem;
220
- color: #444;
221
- }
222
-
223
- .days-row {
224
- grid-column: 1 / -1;
225
- display: flex;
226
- flex-wrap: wrap;
227
- gap: 0.5rem 0.75rem;
228
- font-size: 0.8rem;
229
- }
230
-
231
- .days-row label {
232
- flex-direction: row;
233
- align-items: center;
234
- gap: 0.35rem;
235
- color: #0a0908;
236
- }
237
-
238
- .playlist-header {
239
- display: flex;
240
- align-items: center;
241
- justify-content: space-between;
242
- padding: 1rem 1rem 0.75rem;
243
- border-bottom: 1px solid #e4e1dc;
244
- }
245
-
246
- .playlist-header h2 {
247
- margin: 0;
248
- font-size: 0.95rem;
249
- }
250
-
251
- .playlist-list {
252
- list-style: none;
253
- margin: 0;
254
- padding: 0.5rem;
255
- flex: 1;
256
- overflow-y: auto;
257
- }
258
-
259
- .playlist-empty {
260
- padding: 1.5rem 1rem;
261
- text-align: center;
262
- color: #888;
263
- font-size: 0.875rem;
264
- }
265
-
266
- .playlist-item {
267
- border: 1px solid #e4e1dc;
268
- border-radius: 8px;
269
- padding: 0.65rem;
270
- margin-bottom: 0.5rem;
271
- background: #fafaf9;
272
- }
273
-
274
- .playlist-item-thumb {
275
- width: 100%;
276
- aspect-ratio: 16 / 9;
277
- object-fit: cover;
278
- border-radius: 4px;
279
- background: #e4e1dc;
280
- display: block;
281
- margin-bottom: 0.5rem;
282
- }
283
-
284
- .playlist-item-name {
285
- font-size: 0.8rem;
286
- font-weight: 600;
287
- word-break: break-all;
288
- margin-bottom: 0.35rem;
289
- }
290
-
291
- .playlist-item-meta {
292
- font-size: 0.75rem;
293
- color: #666;
294
- margin-bottom: 0.45rem;
295
- }
296
-
297
- .playlist-item-actions {
298
- display: flex;
299
- gap: 0.35rem;
300
- align-items: center;
301
- }
302
-
303
- .playlist-item-actions input[type="number"] {
304
- width: 5rem;
305
- font: inherit;
306
- padding: 0.25rem 0.4rem;
307
- border: 1px solid #d6d2cb;
308
- border-radius: 4px;
309
- }
310
-
311
- .publish-bar {
312
- padding: 1rem 0 0;
313
- }
314
-
315
- #result {
316
- margin: 0;
317
- padding: 0.75rem;
318
- background: #0a0908;
319
- color: #e8e6e3;
320
- border-radius: 8px;
321
- font-size: 0.75rem;
322
- max-height: 10rem;
323
- overflow: auto;
324
- white-space: pre-wrap;
325
- }
326
-
327
- .hidden {
328
- display: none !important;
329
- }
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
8
+ background: #f4f3f0;
9
+ color: #0a0908;
10
+ }
11
+
12
+ .app-header {
13
+ padding: 1rem 1.5rem;
14
+ background: #0a0908;
15
+ color: #fafaf9;
16
+ }
17
+
18
+ .app-header h1 {
19
+ margin: 0;
20
+ font-size: 1.25rem;
21
+ font-weight: 600;
22
+ }
23
+
24
+ .app-header p {
25
+ margin: 0.35rem 0 0;
26
+ font-size: 0.875rem;
27
+ opacity: 0.8;
28
+ }
29
+
30
+ .layout {
31
+ display: flex;
32
+ gap: 0;
33
+ min-height: calc(100vh - 4.5rem);
34
+ }
35
+
36
+ .panel-main {
37
+ flex: 1;
38
+ padding: 1.25rem 1.5rem 2rem;
39
+ overflow: auto;
40
+ }
41
+
42
+ .panel-playlist {
43
+ width: 320px;
44
+ flex-shrink: 0;
45
+ background: #fff;
46
+ border-left: 1px solid #e4e1dc;
47
+ display: flex;
48
+ flex-direction: column;
49
+ }
50
+
51
+ .card {
52
+ background: #fff;
53
+ border: 1px solid #e4e1dc;
54
+ border-radius: 10px;
55
+ padding: 1rem 1.1rem;
56
+ margin-bottom: 1rem;
57
+ }
58
+
59
+ .card h2 {
60
+ margin: 0 0 0.75rem;
61
+ font-size: 0.95rem;
62
+ font-weight: 600;
63
+ }
64
+
65
+ .hint {
66
+ margin: 0 0 0.75rem;
67
+ font-size: 0.8rem;
68
+ color: #666;
69
+ }
70
+
71
+ .devices-grid {
72
+ display: grid;
73
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
74
+ gap: 0.85rem;
75
+ }
76
+
77
+ .devices-empty {
78
+ margin: 0;
79
+ color: #666;
80
+ font-size: 0.875rem;
81
+ grid-column: 1 / -1;
82
+ }
83
+
84
+ .device-card {
85
+ border: 1px solid #e4e1dc;
86
+ border-radius: 8px;
87
+ padding: 0.85rem 0.95rem;
88
+ background: #fafaf9;
89
+ }
90
+
91
+ .device-card-header {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 0.5rem;
95
+ margin-bottom: 0.65rem;
96
+ }
97
+
98
+ .device-card-title {
99
+ margin: 0;
100
+ font-size: 0.95rem;
101
+ font-weight: 600;
102
+ line-height: 1.2;
103
+ }
104
+
105
+ .status-led {
106
+ width: 11px;
107
+ height: 11px;
108
+ border-radius: 50%;
109
+ flex-shrink: 0;
110
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
111
+ }
112
+
113
+ .status-led--online {
114
+ background: #12b76a;
115
+ box-shadow: 0 0 8px rgba(18, 183, 106, 0.55);
116
+ }
117
+
118
+ .status-led--offline {
119
+ background: #f04438;
120
+ box-shadow: 0 0 8px rgba(240, 68, 56, 0.45);
121
+ }
122
+
123
+ .device-meta {
124
+ display: grid;
125
+ gap: 0.35rem;
126
+ font-size: 0.78rem;
127
+ color: #444;
128
+ margin-bottom: 0.75rem;
129
+ }
130
+
131
+ .device-meta dt {
132
+ font-weight: 600;
133
+ color: #666;
134
+ display: inline;
135
+ }
136
+
137
+ .device-meta dt::after {
138
+ content: ": ";
139
+ }
140
+
141
+ .device-meta dd {
142
+ display: inline;
143
+ margin: 0;
144
+ word-break: break-all;
145
+ }
146
+
147
+ .device-meta-row {
148
+ display: block;
149
+ }
150
+
151
+ .device-card-actions {
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ gap: 0.4rem;
155
+ }
156
+
157
+ .device-card-actions button {
158
+ font-size: 0.78rem;
159
+ padding: 0.35rem 0.6rem;
160
+ }
161
+
162
+ .row {
163
+ display: flex;
164
+ flex-wrap: wrap;
165
+ gap: 0.5rem;
166
+ align-items: center;
167
+ }
168
+
169
+ input[type="text"],
170
+ input[type="date"],
171
+ input[type="time"],
172
+ select {
173
+ font: inherit;
174
+ padding: 0.45rem 0.6rem;
175
+ border: 1px solid #d6d2cb;
176
+ border-radius: 6px;
177
+ background: #fff;
178
+ }
179
+
180
+ button {
181
+ font: inherit;
182
+ padding: 0.45rem 0.85rem;
183
+ border-radius: 6px;
184
+ border: 1px solid #d6d2cb;
185
+ background: #fff;
186
+ cursor: pointer;
187
+ }
188
+
189
+ button:hover {
190
+ background: #f5f3ef;
191
+ }
192
+
193
+ button.primary {
194
+ background: #ff8a3d;
195
+ border-color: #e67320;
196
+ color: #0a0908;
197
+ font-weight: 600;
198
+ }
199
+
200
+ button.primary:hover {
201
+ background: #ff9a57;
202
+ }
203
+
204
+ button.danger {
205
+ color: #b42318;
206
+ border-color: #fecdca;
207
+ }
208
+
209
+ .schedule-grid {
210
+ display: grid;
211
+ grid-template-columns: 1fr 1fr;
212
+ gap: 0.65rem 1rem;
213
+ }
214
+
215
+ .schedule-grid label {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 0.25rem;
219
+ font-size: 0.8rem;
220
+ color: #444;
221
+ }
222
+
223
+ .days-row {
224
+ grid-column: 1 / -1;
225
+ display: flex;
226
+ flex-wrap: wrap;
227
+ gap: 0.5rem 0.75rem;
228
+ font-size: 0.8rem;
229
+ }
230
+
231
+ .days-row label {
232
+ flex-direction: row;
233
+ align-items: center;
234
+ gap: 0.35rem;
235
+ color: #0a0908;
236
+ }
237
+
238
+ .playlist-header {
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: space-between;
242
+ padding: 1rem 1rem 0.75rem;
243
+ border-bottom: 1px solid #e4e1dc;
244
+ }
245
+
246
+ .playlist-header h2 {
247
+ margin: 0;
248
+ font-size: 0.95rem;
249
+ }
250
+
251
+ .playlist-list {
252
+ list-style: none;
253
+ margin: 0;
254
+ padding: 0.5rem;
255
+ flex: 1;
256
+ overflow-y: auto;
257
+ }
258
+
259
+ .playlist-empty {
260
+ padding: 1.5rem 1rem;
261
+ text-align: center;
262
+ color: #888;
263
+ font-size: 0.875rem;
264
+ }
265
+
266
+ .playlist-item {
267
+ border: 1px solid #e4e1dc;
268
+ border-radius: 8px;
269
+ padding: 0.65rem;
270
+ margin-bottom: 0.5rem;
271
+ background: #fafaf9;
272
+ }
273
+
274
+ .playlist-item-thumb {
275
+ width: 100%;
276
+ aspect-ratio: 16 / 9;
277
+ object-fit: cover;
278
+ border-radius: 4px;
279
+ background: #e4e1dc;
280
+ display: block;
281
+ margin-bottom: 0.5rem;
282
+ }
283
+
284
+ .playlist-item-name {
285
+ font-size: 0.8rem;
286
+ font-weight: 600;
287
+ word-break: break-all;
288
+ margin-bottom: 0.35rem;
289
+ }
290
+
291
+ .playlist-item-meta {
292
+ font-size: 0.75rem;
293
+ color: #666;
294
+ margin-bottom: 0.45rem;
295
+ }
296
+
297
+ .playlist-item-actions {
298
+ display: flex;
299
+ gap: 0.35rem;
300
+ align-items: center;
301
+ }
302
+
303
+ .playlist-item-actions input[type="number"] {
304
+ width: 5rem;
305
+ font: inherit;
306
+ padding: 0.25rem 0.4rem;
307
+ border: 1px solid #d6d2cb;
308
+ border-radius: 4px;
309
+ }
310
+
311
+ .publish-bar {
312
+ padding: 1rem 0 0;
313
+ }
314
+
315
+ #result {
316
+ margin: 0;
317
+ padding: 0.75rem;
318
+ background: #0a0908;
319
+ color: #e8e6e3;
320
+ border-radius: 8px;
321
+ font-size: 0.75rem;
322
+ max-height: 10rem;
323
+ overflow: auto;
324
+ white-space: pre-wrap;
325
+ }
326
+
327
+ .hidden {
328
+ display: none !important;
329
+ }