@tomorrowos/sdk 0.1.10 → 0.2.1

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,5 @@
1
1
  export { TomorrowOS } from "./tomorrowos.js";
2
- export type { ListenOptions, TomorrowOSBrand, TomorrowOSOptions } from "./tomorrowos.js";
2
+ export type { DeviceListItem, ListenOptions, TomorrowOSBrand, TomorrowOSOptions } from "./tomorrowos.js";
3
3
  export type { PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./store/types.js";
4
4
  export { MemoryStore } from "./store/memory-store.js";
5
5
  //# 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,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzF,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,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
@@ -1,4 +1,4 @@
1
- import type { PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./types.js";
1
+ import type { 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.
@@ -12,5 +12,6 @@ export declare class MemoryStore implements TomorrowOSStore {
12
12
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
13
13
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
14
14
  deletePairedDevice(deviceId: string): Promise<void>;
15
+ listPairedDevices(): Promise<PairedDeviceEntry[]>;
15
16
  }
16
17
  //# sourceMappingURL=memory-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,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;CAG1D"}
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"}
@@ -23,4 +23,10 @@ export class MemoryStore {
23
23
  async deletePairedDevice(deviceId) {
24
24
  this.pairedDevices.delete(deviceId);
25
25
  }
26
+ async listPairedDevices() {
27
+ return [...this.pairedDevices.entries()].map(([deviceId, record]) => ({
28
+ deviceId,
29
+ record
30
+ }));
31
+ }
26
32
  }
@@ -9,6 +9,17 @@ export interface PendingCodeRecord {
9
9
  export interface PairedDeviceRecord {
10
10
  pairingToken: string;
11
11
  pairedAt: string;
12
+ deviceName?: string;
13
+ platform?: string;
14
+ system?: string;
15
+ lastBootAt?: string;
16
+ lastOnlineAt?: string;
17
+ lastOfflineAt?: string;
18
+ lastPolicyPushAt?: string;
19
+ }
20
+ export interface PairedDeviceEntry {
21
+ deviceId: string;
22
+ record: PairedDeviceRecord;
12
23
  }
13
24
  /**
14
25
  * Implement with Postgres, Redis, etc. Defaults to MemoryStore (Map).
@@ -21,5 +32,6 @@ export interface TomorrowOSStore {
21
32
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
22
33
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
23
34
  deletePairedDevice(deviceId: string): Promise<void>;
35
+ listPairedDevices(): Promise<PairedDeviceEntry[]>;
24
36
  }
25
37
  //# sourceMappingURL=types.d.ts.map
@@ -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;CAClB;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;CACrD"}
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"}
@@ -27,10 +27,27 @@ export interface ListenOptions {
27
27
  */
28
28
  staticIndex?: string;
29
29
  }
30
+ export interface DeviceListItem {
31
+ deviceId: string;
32
+ connected: boolean;
33
+ deviceName: string | null;
34
+ platform: string | null;
35
+ system: string | null;
36
+ pairedAt: string;
37
+ lastBootAt: string | null;
38
+ lastOnlineAt: string | null;
39
+ lastOfflineAt: string | null;
40
+ lastPolicyPushAt: string | null;
41
+ screenOnlineActive: boolean;
42
+ screenOnlineLabel: string;
43
+ /** ISO time when the TV WebSocket session last became active (not CMS browser). */
44
+ screenOnlineSince: string | null;
45
+ }
30
46
  export declare class TomorrowOS extends EventEmitter {
31
47
  readonly brand: TomorrowOSBrand;
32
48
  private readonly store;
33
49
  private readonly devices;
50
+ private readonly pendingDeviceMeta;
34
51
  private httpServer;
35
52
  private wss;
36
53
  private staticRoot;
@@ -54,6 +71,15 @@ export declare class TomorrowOS extends EventEmitter {
54
71
  notified: boolean;
55
72
  }>;
56
73
  };
74
+ /** List paired devices with live connection + timing fields for the CMS panel. */
75
+ listDevices(): Promise<DeviceListItem[]>;
76
+ private isDeviceConnected;
77
+ private captureHelloMeta;
78
+ private mergePairedRecord;
79
+ private touchPairedOnline;
80
+ private touchPairedOffline;
81
+ private recordPolicyPush;
82
+ private refreshPairedDeviceInfo;
57
83
  device(deviceId: string): {
58
84
  sendCommand<T = unknown>(method: string, params?: Record<string, unknown>): Promise<{
59
85
  status: string;
@@ -1 +1 @@
1
- {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAKxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGxD,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAyED,qBAAa,UAAW,SAAQ,YAAY;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAMtC,oEAAoE;IAC9D,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAiChE,6EAA6E;IACvE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;IA2BvF,OAAO;uBACU,MAAM;sBA9DgC,MAAM;;2BA+DxC,MAAM;sBA7BgC,MAAM;sBAAY,OAAO;;MA8BlF;IAEF,MAAM,CAAC,QAAQ,EAAE,MAAM;oBAGD,CAAC,oBACT,MAAM,WACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;;IAU5E,OAAO,CAAC,mBAAmB;IA6D3B,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC,MAAM;YAkD7B,iBAAiB;YAqCjB,cAAc;YAmCd,UAAU;YAkEV,gBAAgB;IAgE9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CAgFzB"}
1
+ {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
@@ -5,6 +5,24 @@ import http from "http";
5
5
  import path from "path";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
7
  import { MemoryStore } from "./store/memory-store.js";
8
+ function formatDurationMs(ms) {
9
+ if (!Number.isFinite(ms) || ms < 0)
10
+ return "0s";
11
+ const totalSec = Math.floor(ms / 1000);
12
+ const days = Math.floor(totalSec / 86400);
13
+ const hours = Math.floor((totalSec % 86400) / 3600);
14
+ const minutes = Math.floor((totalSec % 3600) / 60);
15
+ const seconds = totalSec % 60;
16
+ const parts = [];
17
+ if (days > 0)
18
+ parts.push(`${days}d`);
19
+ if (days > 0 || hours > 0)
20
+ parts.push(`${hours}h`);
21
+ if (days > 0 || hours > 0 || minutes > 0)
22
+ parts.push(`${minutes}m`);
23
+ parts.push(`${seconds}s`);
24
+ return parts.join(" ");
25
+ }
8
26
  function createSixDigitCode() {
9
27
  return Math.floor(100000 + Math.random() * 900000).toString();
10
28
  }
@@ -54,6 +72,7 @@ export class TomorrowOS extends EventEmitter {
54
72
  brand;
55
73
  store;
56
74
  devices = new Map();
75
+ pendingDeviceMeta = new Map();
57
76
  httpServer = null;
58
77
  wss = null;
59
78
  staticRoot = null;
@@ -73,9 +92,17 @@ export class TomorrowOS extends EventEmitter {
73
92
  }
74
93
  const pairingToken = randomBytes(32).toString("hex");
75
94
  const pairedAt = new Date().toISOString();
95
+ const meta = this.pendingDeviceMeta.get(record.deviceId);
96
+ const now = pairedAt;
76
97
  await this.store.setPairedDevice(record.deviceId, {
77
98
  pairingToken,
78
- pairedAt
99
+ pairedAt,
100
+ deviceName: meta?.deviceName,
101
+ platform: meta?.platform,
102
+ system: meta?.system,
103
+ lastBootAt: meta?.bootedAt ?? now,
104
+ lastOnlineAt: this.isDeviceConnected(record.deviceId) ? now : undefined,
105
+ lastOfflineAt: this.isDeviceConnected(record.deviceId) ? undefined : now
79
106
  });
80
107
  await this.store.deletePendingCode(code);
81
108
  const ws = this.devices.get(record.deviceId);
@@ -86,6 +113,7 @@ export class TomorrowOS extends EventEmitter {
86
113
  deviceId: record.deviceId,
87
114
  pairingToken
88
115
  }));
116
+ void this.refreshPairedDeviceInfo(record.deviceId);
89
117
  }
90
118
  this.emit("device.paired", { deviceId: record.deviceId });
91
119
  return { deviceId: record.deviceId };
@@ -99,6 +127,7 @@ export class TomorrowOS extends EventEmitter {
99
127
  throw err;
100
128
  }
101
129
  await this.store.deletePairedDevice(id);
130
+ this.pendingDeviceMeta.delete(id);
102
131
  const ws = this.devices.get(id);
103
132
  let notified = false;
104
133
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -116,6 +145,120 @@ export class TomorrowOS extends EventEmitter {
116
145
  verify: (code) => this.pairingVerify(code),
117
146
  unpair: (deviceId) => this.pairingUnpair(deviceId)
118
147
  };
148
+ /** List paired devices with live connection + timing fields for the CMS panel. */
149
+ async listDevices() {
150
+ const entries = await this.store.listPairedDevices();
151
+ const now = Date.now();
152
+ return entries.map(({ deviceId, record }) => {
153
+ const connected = this.isDeviceConnected(deviceId);
154
+ let screenOnlineActive = false;
155
+ let screenOnlineLabel = "Not active";
156
+ if (connected && record.lastOnlineAt) {
157
+ const since = new Date(record.lastOnlineAt).getTime();
158
+ if (!Number.isNaN(since)) {
159
+ screenOnlineActive = true;
160
+ screenOnlineLabel = formatDurationMs(now - since);
161
+ }
162
+ }
163
+ const screenOnlineSince = connected && record.lastOnlineAt ? record.lastOnlineAt : null;
164
+ return {
165
+ deviceId,
166
+ connected,
167
+ deviceName: record.deviceName ?? null,
168
+ platform: record.platform ?? null,
169
+ system: record.system ?? null,
170
+ pairedAt: record.pairedAt,
171
+ lastBootAt: record.lastBootAt ?? null,
172
+ lastOnlineAt: record.lastOnlineAt ?? null,
173
+ lastOfflineAt: record.lastOfflineAt ?? null,
174
+ lastPolicyPushAt: record.lastPolicyPushAt ?? null,
175
+ screenOnlineActive,
176
+ screenOnlineLabel,
177
+ screenOnlineSince
178
+ };
179
+ });
180
+ }
181
+ isDeviceConnected(deviceId) {
182
+ const ws = this.devices.get(deviceId);
183
+ return !!ws && ws.readyState === WebSocket.OPEN;
184
+ }
185
+ captureHelloMeta(deviceId, msg) {
186
+ this.pendingDeviceMeta.set(deviceId, {
187
+ platform: typeof msg.platform === "string" ? msg.platform : undefined,
188
+ deviceName: typeof msg.deviceName === "string" ? msg.deviceName : undefined,
189
+ system: typeof msg.system === "string" ? msg.system : undefined,
190
+ bootedAt: typeof msg.bootedAt === "string" ? msg.bootedAt : undefined,
191
+ playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined
192
+ });
193
+ }
194
+ async mergePairedRecord(deviceId, patch) {
195
+ const existing = await this.store.getPairedDevice(deviceId);
196
+ if (!existing)
197
+ return;
198
+ await this.store.setPairedDevice(deviceId, { ...existing, ...patch });
199
+ }
200
+ async touchPairedOnline(deviceId, msg) {
201
+ this.captureHelloMeta(deviceId, msg);
202
+ const now = new Date().toISOString();
203
+ const bootedAt = typeof msg.bootedAt === "string" ? msg.bootedAt : undefined;
204
+ const existing = await this.store.getPairedDevice(deviceId);
205
+ if (!existing)
206
+ return;
207
+ await this.store.setPairedDevice(deviceId, {
208
+ ...existing,
209
+ deviceName: (typeof msg.deviceName === "string" ? msg.deviceName : undefined) ??
210
+ existing.deviceName,
211
+ platform: (typeof msg.platform === "string" ? msg.platform : undefined) ??
212
+ existing.platform,
213
+ system: (typeof msg.system === "string" ? msg.system : undefined) ??
214
+ existing.system,
215
+ lastBootAt: bootedAt ?? existing.lastBootAt ?? now,
216
+ lastOnlineAt: now,
217
+ lastOfflineAt: existing.lastOfflineAt
218
+ });
219
+ }
220
+ async touchPairedOffline(deviceId) {
221
+ const existing = await this.store.getPairedDevice(deviceId);
222
+ if (!existing)
223
+ return;
224
+ await this.store.setPairedDevice(deviceId, {
225
+ ...existing,
226
+ lastOfflineAt: new Date().toISOString()
227
+ });
228
+ }
229
+ async recordPolicyPush(deviceId) {
230
+ await this.mergePairedRecord(deviceId, {
231
+ lastPolicyPushAt: new Date().toISOString()
232
+ });
233
+ }
234
+ async refreshPairedDeviceInfo(deviceId) {
235
+ const ws = this.devices.get(deviceId);
236
+ if (!ws || ws.readyState !== WebSocket.OPEN)
237
+ return;
238
+ const existing = await this.store.getPairedDevice(deviceId);
239
+ if (!existing)
240
+ return;
241
+ try {
242
+ const result = await this.sendCommandToSocket(ws, deviceId, "device.info.get", {}, 15_000);
243
+ if (result.status !== "success" || !result.data)
244
+ return;
245
+ const model = typeof result.data.model === "string" ? result.data.model : undefined;
246
+ const firmware = typeof result.data.firmware === "string"
247
+ ? result.data.firmware
248
+ : undefined;
249
+ await this.store.setPairedDevice(deviceId, {
250
+ ...existing,
251
+ deviceName: model ?? existing.deviceName,
252
+ system: firmware
253
+ ? `Tizen (${firmware})`
254
+ : existing.system,
255
+ platform: existing.platform ?? "tizen"
256
+ });
257
+ }
258
+ catch {
259
+ /* ignore — panel still shows hello metadata */
260
+ }
261
+ }
119
262
  device(deviceId) {
120
263
  const self = this;
121
264
  return {
@@ -327,6 +470,11 @@ export class TomorrowOS extends EventEmitter {
327
470
  }
328
471
  return;
329
472
  }
473
+ if (req.method === "GET" && pathname === "/devices") {
474
+ const devices = await this.listDevices();
475
+ sendJson(res, 200, { status: "success", devices });
476
+ return;
477
+ }
330
478
  if (req.method === "POST") {
331
479
  const parsed = parseDevicePath(pathname);
332
480
  if (parsed) {
@@ -390,6 +538,9 @@ export class TomorrowOS extends EventEmitter {
390
538
  });
391
539
  return;
392
540
  }
541
+ if (action === "content/set-policy" && result.status === "success") {
542
+ await this.recordPolicyPush(deviceId);
543
+ }
393
544
  sendJson(res, 200, { status: result.status, data: result.data });
394
545
  }
395
546
  catch (e) {
@@ -421,6 +572,7 @@ export class TomorrowOS extends EventEmitter {
421
572
  ? msg.deviceId
422
573
  : randomUUID();
423
574
  const code = createSixDigitCode();
575
+ this.captureHelloMeta(deviceId, msg);
424
576
  this.devices.set(deviceId, ws);
425
577
  void this.store.setPendingCode(code, {
426
578
  deviceId,
@@ -449,27 +601,33 @@ export class TomorrowOS extends EventEmitter {
449
601
  }));
450
602
  return;
451
603
  }
604
+ this.captureHelloMeta(deviceId, msg);
452
605
  this.devices.set(deviceId, ws);
453
606
  ws.deviceId = deviceId;
607
+ await this.touchPairedOnline(deviceId, msg);
454
608
  ws.send(JSON.stringify({
455
609
  type: "device.resumed",
456
610
  method: "tomorrowos.pairing.resume",
457
611
  deviceId
458
612
  }));
459
613
  this.sendBrandSnapshot(ws);
614
+ void this.refreshPairedDeviceInfo(deviceId);
460
615
  this.emit("device.online", { deviceId });
461
616
  })();
462
617
  }
463
618
  });
464
619
  ws.on("close", () => {
465
620
  const id = ws.deviceId;
466
- if (id) {
467
- this.devices.delete(id);
468
- this.emit("device.offline", {
469
- deviceId: id,
470
- lastSeen: new Date().toISOString()
471
- });
472
- }
621
+ if (!id)
622
+ return;
623
+ if (this.devices.get(id) !== ws)
624
+ return;
625
+ this.devices.delete(id);
626
+ void this.touchPairedOffline(id);
627
+ this.emit("device.offline", {
628
+ deviceId: id,
629
+ lastSeen: new Date().toISOString()
630
+ });
473
631
  });
474
632
  }
475
633
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.1.10",
3
+ "version": "0.2.1",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "my-cms",
3
- "version": "0.1.10",
3
+ "version": "0.2.1",
4
4
  "description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
5
5
  "private": true,
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  "build-player": "tomorrowos build --platform tizen"
11
11
  },
12
12
  "dependencies": {
13
- "@tomorrowos/sdk": "^0.1.10"
13
+ "@tomorrowos/sdk": "^0.2.1"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^20.0.0",
@@ -9,26 +9,32 @@
9
9
  <body>
10
10
  <header class="app-header">
11
11
  <h1>TomorrowOS Control Panel</h1>
12
- <p>Build a playlist, set schedule, then publish to paired screens.</p>
12
+ <p>Pair multiple screens, build a playlist, then publish per device.</p>
13
13
  </header>
14
14
 
15
15
  <div class="layout">
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
20
  <div class="row">
20
21
  <input id="code" maxlength="6" placeholder="6-digit code" />
21
22
  <button type="button" onclick="verify()">Verify</button>
22
- <button type="button" class="danger" onclick="unpair()">Unpair</button>
23
23
  </div>
24
- <input type="hidden" id="deviceId" />
24
+ </section>
25
+
26
+ <section class="card" id="pairedDevicesSection">
27
+ <h2>Paired devices</h2>
28
+ <div id="devicesGrid" class="devices-grid">
29
+ <p class="devices-empty" id="devicesEmpty">No paired devices yet. Enter a code above.</p>
30
+ </div>
25
31
  </section>
26
32
 
27
33
  <section class="card">
28
34
  <h2>CMS URL for screens</h2>
29
- <p style="margin: 0 0 0.5rem; font-size: 0.8rem; color: #666">
30
- HTTP(S) base URL your TV uses to download uploads (same host as <code>ws://</code> on the
31
- device, not <code>localhost</code>).
35
+ <p class="hint">
36
+ HTTP(S) base URL your TVs use to download uploads (same host as <code>ws://</code>, not
37
+ <code>localhost</code>).
32
38
  </p>
33
39
  <div class="row">
34
40
  <input
@@ -43,7 +49,7 @@
43
49
 
44
50
  <section class="card">
45
51
  <h2>When this playlist plays</h2>
46
- <p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
52
+ <p class="hint">
47
53
  Leave blank for always on (device local time). All set fields must match.
48
54
  </p>
49
55
  <div class="schedule-grid">
@@ -76,22 +82,6 @@
76
82
  </div>
77
83
  </section>
78
84
 
79
- <section class="card">
80
- <h2>Device actions</h2>
81
- <div class="row">
82
- <button type="button" onclick="getInfo()">Get info</button>
83
- <button type="button" onclick="getCapabilities()">Capabilities</button>
84
- <button type="button" onclick="reboot()">Reboot</button>
85
- <button type="button" class="danger" onclick="clearContent()">Clear content</button>
86
- </div>
87
- </section>
88
-
89
- <section class="publish-bar">
90
- <button type="button" class="primary" style="width: 100%; padding: 0.75rem" onclick="publish()">
91
- Publish playlist
92
- </button>
93
- </section>
94
-
95
85
  <pre id="result" aria-live="polite"></pre>
96
86
  </main>
97
87
 
@@ -1,4 +1,3 @@
1
- const PANEL_DEVICE_ID_KEY = "tomorrowos.panel.deviceId";
2
1
  const PANEL_PLAYLIST_KEY = "tomorrowos.panel.playlistDraft";
3
2
  const PANEL_SCHEDULE_KEY = "tomorrowos.panel.scheduleDraft";
4
3
  const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
@@ -6,20 +5,207 @@ const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
6
5
  /** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
7
6
  let playlistItems = [];
8
7
 
9
- function getPanelDeviceId() {
10
- return (
11
- document.getElementById("deviceId")?.value?.trim() ||
12
- localStorage.getItem(PANEL_DEVICE_ID_KEY) ||
13
- ""
14
- );
8
+ /** @type {Array<Record<string, unknown>>} */
9
+ let devicesCache = [];
10
+
11
+ let devicePollTimer = null;
12
+ let deviceUptimeTimer = null;
13
+
14
+ function escapeHtml(value) {
15
+ return String(value ?? "")
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;");
20
+ }
21
+
22
+ function formatDurationMs(ms) {
23
+ if (!Number.isFinite(ms) || ms < 0) return "0s";
24
+ const totalSec = Math.floor(ms / 1000);
25
+ const days = Math.floor(totalSec / 86400);
26
+ const hours = Math.floor((totalSec % 86400) / 3600);
27
+ const minutes = Math.floor((totalSec % 3600) / 60);
28
+ const seconds = totalSec % 60;
29
+ const parts = [];
30
+ if (days > 0) parts.push(`${days}d`);
31
+ if (days > 0 || hours > 0) parts.push(`${hours}h`);
32
+ if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`);
33
+ parts.push(`${seconds}s`);
34
+ return parts.join(" ");
35
+ }
36
+
37
+ function formatDateTimeSeconds(iso) {
38
+ if (!iso) return "—";
39
+ const d = new Date(iso);
40
+ if (Number.isNaN(d.getTime())) return "—";
41
+ return d.toLocaleString(undefined, {
42
+ year: "numeric",
43
+ month: "short",
44
+ day: "numeric",
45
+ hour: "2-digit",
46
+ minute: "2-digit",
47
+ second: "2-digit"
48
+ });
49
+ }
50
+
51
+ /** TV online duration since last WebSocket active (device.lastOnlineAt), not this browser tab. */
52
+ function formatDeviceOnlineLabel(device) {
53
+ if (!device.connected) return "Not active";
54
+ const sinceIso = device.screenOnlineSince || device.lastOnlineAt;
55
+ if (!sinceIso) return "Not active";
56
+ const since = new Date(sinceIso).getTime();
57
+ if (Number.isNaN(since)) return "Not active";
58
+ return formatDurationMs(Date.now() - since);
59
+ }
60
+
61
+ function updateDeviceUptimeLabels() {
62
+ document.querySelectorAll(".device-card[data-device-id]").forEach((card) => {
63
+ const deviceId = card.dataset.deviceId;
64
+ const device = devicesCache.find((d) => d.deviceId === deviceId);
65
+ if (!device) return;
66
+ const el = card.querySelector(".device-online-time");
67
+ if (el) el.textContent = formatDeviceOnlineLabel(device);
68
+ });
69
+ }
70
+
71
+ async function fetchDevices() {
72
+ try {
73
+ const res = await fetch("/devices");
74
+ const data = await res.json();
75
+ if (Array.isArray(data.devices)) {
76
+ devicesCache = data.devices;
77
+ renderDeviceCards();
78
+ }
79
+ } catch (err) {
80
+ showResult({ status: "failed", error: err.message });
81
+ }
82
+ }
83
+
84
+ function renderDeviceCards() {
85
+ const grid = document.getElementById("devicesGrid");
86
+ if (!grid) return;
87
+
88
+ grid.innerHTML = "";
89
+
90
+ if (devicesCache.length === 0) {
91
+ const empty = document.createElement("p");
92
+ empty.className = "devices-empty";
93
+ empty.textContent = "No paired devices yet. Enter a code above.";
94
+ grid.appendChild(empty);
95
+ return;
96
+ }
97
+
98
+ for (const device of devicesCache) {
99
+ const card = document.createElement("article");
100
+ card.className = "device-card";
101
+ card.dataset.deviceId = device.deviceId;
102
+
103
+ const header = document.createElement("div");
104
+ header.className = "device-card-header";
105
+
106
+ const led = document.createElement("span");
107
+ led.className = `status-led ${device.connected ? "status-led--online" : "status-led--offline"}`;
108
+ led.title = device.connected ? "Connected" : "Disconnected";
109
+
110
+ const title = document.createElement("h3");
111
+ title.className = "device-card-title";
112
+ title.textContent = device.deviceName || "Screen";
113
+
114
+ header.appendChild(led);
115
+ header.appendChild(title);
116
+
117
+ const meta = document.createElement("dl");
118
+ meta.className = "device-meta";
119
+
120
+ const rows = [
121
+ ["Device ID", device.deviceId],
122
+ ["System", device.system || device.platform || "—"],
123
+ [
124
+ "Device online",
125
+ formatDeviceOnlineLabel(device)
126
+ ],
127
+ ["Last boot", formatDateTimeSeconds(device.lastBootAt)],
128
+ ["Latest content push", formatDateTimeSeconds(device.lastPolicyPushAt)]
129
+ ];
130
+
131
+ for (const [label, value] of rows) {
132
+ const row = document.createElement("div");
133
+ row.className = "device-meta-row";
134
+ const dt = document.createElement("dt");
135
+ dt.textContent = label;
136
+ const dd = document.createElement("dd");
137
+ if (label === "Device online") {
138
+ dd.className = "device-online-time";
139
+ dd.title =
140
+ "Time since this TV's WebSocket session became active (updates every minute)";
141
+ }
142
+ dd.textContent = value;
143
+ row.appendChild(dt);
144
+ row.appendChild(dd);
145
+ meta.appendChild(row);
146
+ }
147
+
148
+ const actions = document.createElement("div");
149
+ actions.className = "device-card-actions";
150
+
151
+ const publishBtn = document.createElement("button");
152
+ publishBtn.type = "button";
153
+ publishBtn.className = "primary";
154
+ publishBtn.textContent = "Publish";
155
+ publishBtn.addEventListener("click", () => publishToDevice(device.deviceId));
156
+
157
+ const infoBtn = document.createElement("button");
158
+ infoBtn.type = "button";
159
+ infoBtn.textContent = "Info";
160
+ infoBtn.addEventListener("click", () => deviceAction(device.deviceId, "get-info"));
161
+
162
+ const capsBtn = document.createElement("button");
163
+ capsBtn.type = "button";
164
+ capsBtn.textContent = "Capabilities";
165
+ capsBtn.addEventListener("click", () =>
166
+ deviceAction(device.deviceId, "get-capabilities")
167
+ );
168
+
169
+ const rebootBtn = document.createElement("button");
170
+ rebootBtn.type = "button";
171
+ rebootBtn.textContent = "Reboot";
172
+ rebootBtn.addEventListener("click", () => deviceAction(device.deviceId, "reboot"));
173
+
174
+ const clearBtn = document.createElement("button");
175
+ clearBtn.type = "button";
176
+ clearBtn.textContent = "Clear";
177
+ clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
178
+
179
+ const unpairBtn = document.createElement("button");
180
+ unpairBtn.type = "button";
181
+ unpairBtn.className = "danger";
182
+ unpairBtn.textContent = "Unpair";
183
+ unpairBtn.addEventListener("click", () => unpairDevice(device.deviceId));
184
+
185
+ actions.appendChild(publishBtn);
186
+ actions.appendChild(infoBtn);
187
+ actions.appendChild(capsBtn);
188
+ actions.appendChild(rebootBtn);
189
+ actions.appendChild(clearBtn);
190
+ actions.appendChild(unpairBtn);
191
+
192
+ card.appendChild(header);
193
+ card.appendChild(meta);
194
+ card.appendChild(actions);
195
+ grid.appendChild(card);
196
+ }
15
197
  }
16
198
 
17
- function setPanelDeviceId(deviceId) {
18
- const id = String(deviceId || "").trim();
19
- const el = document.getElementById("deviceId");
20
- if (el) el.value = id;
21
- if (id) localStorage.setItem(PANEL_DEVICE_ID_KEY, id);
22
- else localStorage.removeItem(PANEL_DEVICE_ID_KEY);
199
+ function startDevicePolling() {
200
+ if (devicePollTimer) clearInterval(devicePollTimer);
201
+ if (deviceUptimeTimer) clearInterval(deviceUptimeTimer);
202
+
203
+ void fetchDevices();
204
+ devicePollTimer = setInterval(() => {
205
+ void fetchDevices();
206
+ }, 8000);
207
+
208
+ deviceUptimeTimer = setInterval(updateDeviceUptimeLabels, 60_000);
23
209
  }
24
210
 
25
211
  function showResult(data) {
@@ -327,20 +513,16 @@ async function verify() {
327
513
  showResult(data);
328
514
 
329
515
  if (data.deviceId) {
330
- setPanelDeviceId(data.deviceId);
516
+ const codeInput = document.getElementById("code");
517
+ if (codeInput) codeInput.value = "";
518
+ await fetchDevices();
331
519
  }
332
520
  }
333
521
 
334
- async function unpair() {
335
- const deviceId = getPanelDeviceId();
336
- if (!deviceId) {
337
- alert("No paired device in this panel.");
338
- return;
339
- }
340
-
522
+ async function unpairDevice(deviceId) {
341
523
  if (
342
524
  !confirm(
343
- "Unpair this device? The screen will show a new 6-digit code and this panel will forget the device."
525
+ "Unpair this device? Its card will be removed and the screen will show a new pairing code."
344
526
  )
345
527
  ) {
346
528
  return;
@@ -356,19 +538,15 @@ async function unpair() {
356
538
  showResult(data);
357
539
 
358
540
  if (res.ok) {
359
- setPanelDeviceId("");
360
- const codeInput = document.getElementById("code");
361
- if (codeInput) codeInput.value = "";
541
+ await fetchDevices();
362
542
  } else {
363
543
  alert(data.error || "Unpair failed");
364
544
  }
365
545
  }
366
546
 
367
- async function publish() {
368
- const deviceId = getPanelDeviceId();
369
-
547
+ async function publishToDevice(deviceId) {
370
548
  if (!deviceId) {
371
- alert("Pair a device first (enter code and Verify).");
549
+ alert("Unknown device.");
372
550
  return;
373
551
  }
374
552
 
@@ -407,55 +585,27 @@ async function publish() {
407
585
  });
408
586
 
409
587
  const data = await res.json();
410
- showResult({ publish: payload, response: data });
411
- }
588
+ showResult({ deviceId, publish: payload, response: data });
412
589
 
413
- async function getInfo() {
414
- const deviceId = getPanelDeviceId();
415
- if (!deviceId) {
416
- alert("Please pair a device first.");
417
- return;
590
+ if (res.ok) {
591
+ await fetchDevices();
418
592
  }
419
- const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
420
- showResult(await res.json());
421
593
  }
422
594
 
423
- async function getCapabilities() {
424
- const deviceId = getPanelDeviceId();
425
- if (!deviceId) {
426
- alert("Please pair a device first.");
427
- return;
428
- }
429
- const res = await fetch(`/device/${deviceId}/get-capabilities`, { method: "POST" });
430
- showResult(await res.json());
431
- }
595
+ async function deviceAction(deviceId, action) {
596
+ if (!deviceId) return;
432
597
 
433
- async function reboot() {
434
- const deviceId = getPanelDeviceId();
435
- if (!deviceId) {
436
- alert("Please pair a device first.");
437
- return;
438
- }
439
- if (!confirm("Reboot this device?")) return;
440
- const res = await fetch(`/device/${deviceId}/reboot`, { method: "POST" });
441
- showResult(await res.json());
442
- }
598
+ if (action === "reboot" && !confirm("Reboot this device?")) return;
599
+ if (action === "content/clear" && !confirm("Clear content on this device?")) return;
443
600
 
444
- async function clearContent() {
445
- const deviceId = getPanelDeviceId();
446
- if (!deviceId) {
447
- alert("Please pair a device first.");
448
- return;
449
- }
450
- if (!confirm("Clear content on this device?")) return;
451
- const res = await fetch(`/device/${deviceId}/content/clear`, { method: "POST" });
452
- showResult(await res.json());
601
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
602
+ method: "POST"
603
+ });
604
+ const data = await res.json();
605
+ showResult({ deviceId, action, ...data });
453
606
  }
454
607
 
455
608
  document.addEventListener("DOMContentLoaded", () => {
456
- const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
457
- if (saved) setPanelDeviceId(saved);
458
-
459
609
  const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
460
610
  const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
461
611
  if (savedMediaBase && cmsBaseInput) {
@@ -466,6 +616,7 @@ document.addEventListener("DOMContentLoaded", () => {
466
616
 
467
617
  loadPlaylistDraft();
468
618
  loadScheduleDraft();
619
+ startDevicePolling();
469
620
 
470
621
  document.getElementById("addAssetBtn").addEventListener("click", () => {
471
622
  document.getElementById("fileInput").click();
@@ -62,6 +62,103 @@ body {
62
62
  font-weight: 600;
63
63
  }
64
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
+
65
162
  .row {
66
163
  display: flex;
67
164
  flex-wrap: wrap;