@tomorrowos/sdk 0.1.9 → 0.2.0

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.
@@ -11,5 +11,7 @@ export declare class MemoryStore implements TomorrowOSStore {
11
11
  deletePendingCode(code: string): Promise<void>;
12
12
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
13
13
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
14
+ deletePairedDevice(deviceId: string): Promise<void>;
15
+ listPairedDevices(): Promise<PairedDeviceEntry[]>;
14
16
  }
15
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;CAG3C"}
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"}
@@ -20,4 +20,13 @@ export class MemoryStore {
20
20
  async getPairedDevice(deviceId) {
21
21
  return this.pairedDevices.get(deviceId);
22
22
  }
23
+ async deletePairedDevice(deviceId) {
24
+ this.pairedDevices.delete(deviceId);
25
+ }
26
+ async listPairedDevices() {
27
+ return [...this.pairedDevices.entries()].map(([deviceId, record]) => ({
28
+ deviceId,
29
+ record
30
+ }));
31
+ }
23
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).
@@ -20,5 +31,7 @@ export interface TomorrowOSStore {
20
31
  deletePendingCode(code: string): Promise<void>;
21
32
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
22
33
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
34
+ deletePairedDevice(deviceId: string): Promise<void>;
35
+ listPairedDevices(): Promise<PairedDeviceEntry[]>;
23
36
  }
24
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;CAC5E"}
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,25 @@ 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
+ }
30
44
  export declare class TomorrowOS extends EventEmitter {
31
45
  readonly brand: TomorrowOSBrand;
32
46
  private readonly store;
33
47
  private readonly devices;
48
+ private readonly pendingDeviceMeta;
34
49
  private httpServer;
35
50
  private wss;
36
51
  private staticRoot;
@@ -40,11 +55,29 @@ export declare class TomorrowOS extends EventEmitter {
40
55
  pairingVerify(code: string): Promise<{
41
56
  deviceId: string;
42
57
  }>;
58
+ /** Remove pairing for a device and notify it over WebSocket if connected. */
59
+ pairingUnpair(deviceId: string): Promise<{
60
+ deviceId: string;
61
+ notified: boolean;
62
+ }>;
43
63
  pairing: {
44
64
  verify: (code: string) => Promise<{
45
65
  deviceId: string;
46
66
  }>;
67
+ unpair: (deviceId: string) => Promise<{
68
+ deviceId: string;
69
+ notified: boolean;
70
+ }>;
47
71
  };
72
+ /** List paired devices with live connection + timing fields for the CMS panel. */
73
+ listDevices(): Promise<DeviceListItem[]>;
74
+ private isDeviceConnected;
75
+ private captureHelloMeta;
76
+ private mergePairedRecord;
77
+ private touchPairedOnline;
78
+ private touchPairedOffline;
79
+ private recordPolicyPush;
80
+ private refreshPairedDeviceInfo;
48
81
  device(deviceId: string): {
49
82
  sendCommand<T = unknown>(method: string, params?: Record<string, unknown>): Promise<{
50
83
  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;AAwED,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,OAAO;uBACU,MAAM;sBAlCgC,MAAM;;MAmC3D;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;YAqDV,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,25 @@ 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 (hours > 0)
20
+ parts.push(`${hours}h`);
21
+ if (minutes > 0)
22
+ parts.push(`${minutes}m`);
23
+ if (parts.length === 0)
24
+ parts.push(`${seconds}s`);
25
+ return parts.join(" ");
26
+ }
8
27
  function createSixDigitCode() {
9
28
  return Math.floor(100000 + Math.random() * 900000).toString();
10
29
  }
@@ -54,6 +73,7 @@ export class TomorrowOS extends EventEmitter {
54
73
  brand;
55
74
  store;
56
75
  devices = new Map();
76
+ pendingDeviceMeta = new Map();
57
77
  httpServer = null;
58
78
  wss = null;
59
79
  staticRoot = null;
@@ -73,9 +93,17 @@ export class TomorrowOS extends EventEmitter {
73
93
  }
74
94
  const pairingToken = randomBytes(32).toString("hex");
75
95
  const pairedAt = new Date().toISOString();
96
+ const meta = this.pendingDeviceMeta.get(record.deviceId);
97
+ const now = pairedAt;
76
98
  await this.store.setPairedDevice(record.deviceId, {
77
99
  pairingToken,
78
- pairedAt
100
+ pairedAt,
101
+ deviceName: meta?.deviceName,
102
+ platform: meta?.platform,
103
+ system: meta?.system,
104
+ lastBootAt: meta?.bootedAt ?? now,
105
+ lastOnlineAt: this.isDeviceConnected(record.deviceId) ? now : undefined,
106
+ lastOfflineAt: this.isDeviceConnected(record.deviceId) ? undefined : now
79
107
  });
80
108
  await this.store.deletePendingCode(code);
81
109
  const ws = this.devices.get(record.deviceId);
@@ -86,13 +114,150 @@ export class TomorrowOS extends EventEmitter {
86
114
  deviceId: record.deviceId,
87
115
  pairingToken
88
116
  }));
117
+ void this.refreshPairedDeviceInfo(record.deviceId);
89
118
  }
90
119
  this.emit("device.paired", { deviceId: record.deviceId });
91
120
  return { deviceId: record.deviceId };
92
121
  }
122
+ /** Remove pairing for a device and notify it over WebSocket if connected. */
123
+ async pairingUnpair(deviceId) {
124
+ const id = String(deviceId || "").trim();
125
+ if (!id) {
126
+ const err = new Error("deviceId is required");
127
+ err.code = "PAIRING_INVALID";
128
+ throw err;
129
+ }
130
+ await this.store.deletePairedDevice(id);
131
+ this.pendingDeviceMeta.delete(id);
132
+ const ws = this.devices.get(id);
133
+ let notified = false;
134
+ if (ws && ws.readyState === WebSocket.OPEN) {
135
+ ws.send(JSON.stringify({
136
+ type: "pairing.unpaired",
137
+ method: "tomorrowos.pairing.unpair",
138
+ deviceId: id
139
+ }));
140
+ notified = true;
141
+ }
142
+ this.emit("device.unpaired", { deviceId: id });
143
+ return { deviceId: id, notified };
144
+ }
93
145
  pairing = {
94
- verify: (code) => this.pairingVerify(code)
146
+ verify: (code) => this.pairingVerify(code),
147
+ unpair: (deviceId) => this.pairingUnpair(deviceId)
95
148
  };
149
+ /** List paired devices with live connection + timing fields for the CMS panel. */
150
+ async listDevices() {
151
+ const entries = await this.store.listPairedDevices();
152
+ const now = Date.now();
153
+ return entries.map(({ deviceId, record }) => {
154
+ const connected = this.isDeviceConnected(deviceId);
155
+ let screenOnlineActive = false;
156
+ let screenOnlineLabel = "Not active";
157
+ if (connected && record.lastOnlineAt) {
158
+ const since = new Date(record.lastOnlineAt).getTime();
159
+ if (!Number.isNaN(since)) {
160
+ screenOnlineActive = true;
161
+ screenOnlineLabel = formatDurationMs(now - since);
162
+ }
163
+ }
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
+ };
178
+ });
179
+ }
180
+ isDeviceConnected(deviceId) {
181
+ const ws = this.devices.get(deviceId);
182
+ return !!ws && ws.readyState === WebSocket.OPEN;
183
+ }
184
+ captureHelloMeta(deviceId, msg) {
185
+ this.pendingDeviceMeta.set(deviceId, {
186
+ platform: typeof msg.platform === "string" ? msg.platform : undefined,
187
+ deviceName: typeof msg.deviceName === "string" ? msg.deviceName : undefined,
188
+ system: typeof msg.system === "string" ? msg.system : undefined,
189
+ bootedAt: typeof msg.bootedAt === "string" ? msg.bootedAt : undefined,
190
+ playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined
191
+ });
192
+ }
193
+ async mergePairedRecord(deviceId, patch) {
194
+ const existing = await this.store.getPairedDevice(deviceId);
195
+ if (!existing)
196
+ return;
197
+ await this.store.setPairedDevice(deviceId, { ...existing, ...patch });
198
+ }
199
+ async touchPairedOnline(deviceId, msg) {
200
+ this.captureHelloMeta(deviceId, msg);
201
+ const now = new Date().toISOString();
202
+ const bootedAt = typeof msg.bootedAt === "string" ? msg.bootedAt : undefined;
203
+ const existing = await this.store.getPairedDevice(deviceId);
204
+ if (!existing)
205
+ return;
206
+ await this.store.setPairedDevice(deviceId, {
207
+ ...existing,
208
+ deviceName: (typeof msg.deviceName === "string" ? msg.deviceName : undefined) ??
209
+ existing.deviceName,
210
+ platform: (typeof msg.platform === "string" ? msg.platform : undefined) ??
211
+ existing.platform,
212
+ system: (typeof msg.system === "string" ? msg.system : undefined) ??
213
+ existing.system,
214
+ lastBootAt: bootedAt ?? existing.lastBootAt ?? now,
215
+ lastOnlineAt: now,
216
+ lastOfflineAt: existing.lastOfflineAt
217
+ });
218
+ }
219
+ async touchPairedOffline(deviceId) {
220
+ const existing = await this.store.getPairedDevice(deviceId);
221
+ if (!existing)
222
+ return;
223
+ await this.store.setPairedDevice(deviceId, {
224
+ ...existing,
225
+ lastOfflineAt: new Date().toISOString()
226
+ });
227
+ }
228
+ async recordPolicyPush(deviceId) {
229
+ await this.mergePairedRecord(deviceId, {
230
+ lastPolicyPushAt: new Date().toISOString()
231
+ });
232
+ }
233
+ async refreshPairedDeviceInfo(deviceId) {
234
+ const ws = this.devices.get(deviceId);
235
+ if (!ws || ws.readyState !== WebSocket.OPEN)
236
+ return;
237
+ const existing = await this.store.getPairedDevice(deviceId);
238
+ if (!existing)
239
+ return;
240
+ try {
241
+ const result = await this.sendCommandToSocket(ws, deviceId, "device.info.get", {}, 15_000);
242
+ if (result.status !== "success" || !result.data)
243
+ return;
244
+ const model = typeof result.data.model === "string" ? result.data.model : undefined;
245
+ const firmware = typeof result.data.firmware === "string"
246
+ ? result.data.firmware
247
+ : undefined;
248
+ await this.store.setPairedDevice(deviceId, {
249
+ ...existing,
250
+ deviceName: model ?? existing.deviceName,
251
+ system: firmware
252
+ ? `Tizen (${firmware})`
253
+ : existing.system,
254
+ platform: existing.platform ?? "tizen"
255
+ });
256
+ }
257
+ catch {
258
+ /* ignore — panel still shows hello metadata */
259
+ }
260
+ }
96
261
  device(deviceId) {
97
262
  const self = this;
98
263
  return {
@@ -291,6 +456,24 @@ export class TomorrowOS extends EventEmitter {
291
456
  }
292
457
  return;
293
458
  }
459
+ if (req.method === "POST" && pathname === "/pairing/unpair") {
460
+ const body = (await readJsonBody(req));
461
+ const deviceId = typeof body.deviceId === "string" ? body.deviceId : "";
462
+ try {
463
+ const result = await this.pairingUnpair(deviceId);
464
+ sendJson(res, 200, { status: "success", ...result });
465
+ }
466
+ catch (e) {
467
+ const msg = e instanceof Error ? e.message : "Unpair failed";
468
+ sendJson(res, 400, { status: "failed", error: msg });
469
+ }
470
+ return;
471
+ }
472
+ if (req.method === "GET" && pathname === "/devices") {
473
+ const devices = await this.listDevices();
474
+ sendJson(res, 200, { status: "success", devices });
475
+ return;
476
+ }
294
477
  if (req.method === "POST") {
295
478
  const parsed = parseDevicePath(pathname);
296
479
  if (parsed) {
@@ -354,6 +537,9 @@ export class TomorrowOS extends EventEmitter {
354
537
  });
355
538
  return;
356
539
  }
540
+ if (action === "content/set-policy" && result.status === "success") {
541
+ await this.recordPolicyPush(deviceId);
542
+ }
357
543
  sendJson(res, 200, { status: result.status, data: result.data });
358
544
  }
359
545
  catch (e) {
@@ -385,6 +571,7 @@ export class TomorrowOS extends EventEmitter {
385
571
  ? msg.deviceId
386
572
  : randomUUID();
387
573
  const code = createSixDigitCode();
574
+ this.captureHelloMeta(deviceId, msg);
388
575
  this.devices.set(deviceId, ws);
389
576
  void this.store.setPendingCode(code, {
390
577
  deviceId,
@@ -413,27 +600,33 @@ export class TomorrowOS extends EventEmitter {
413
600
  }));
414
601
  return;
415
602
  }
603
+ this.captureHelloMeta(deviceId, msg);
416
604
  this.devices.set(deviceId, ws);
417
605
  ws.deviceId = deviceId;
606
+ await this.touchPairedOnline(deviceId, msg);
418
607
  ws.send(JSON.stringify({
419
608
  type: "device.resumed",
420
609
  method: "tomorrowos.pairing.resume",
421
610
  deviceId
422
611
  }));
423
612
  this.sendBrandSnapshot(ws);
613
+ void this.refreshPairedDeviceInfo(deviceId);
424
614
  this.emit("device.online", { deviceId });
425
615
  })();
426
616
  }
427
617
  });
428
618
  ws.on("close", () => {
429
619
  const id = ws.deviceId;
430
- if (id) {
431
- this.devices.delete(id);
432
- this.emit("device.offline", {
433
- deviceId: id,
434
- lastSeen: new Date().toISOString()
435
- });
436
- }
620
+ if (!id)
621
+ return;
622
+ if (this.devices.get(id) !== ws)
623
+ return;
624
+ this.devices.delete(id);
625
+ void this.touchPairedOffline(id);
626
+ this.emit("device.offline", {
627
+ deviceId: id,
628
+ lastSeen: new Date().toISOString()
629
+ });
437
630
  });
438
631
  }
439
632
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
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.9",
3
+ "version": "0.2.0",
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.9"
13
+ "@tomorrowos/sdk": "^0.2.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^20.0.0",
@@ -9,25 +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
23
  </div>
23
- <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>
24
31
  </section>
25
32
 
26
33
  <section class="card">
27
34
  <h2>CMS URL for screens</h2>
28
- <p style="margin: 0 0 0.5rem; font-size: 0.8rem; color: #666">
29
- HTTP(S) base URL your TV uses to download uploads (same host as <code>ws://</code> on the
30
- 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>).
31
38
  </p>
32
39
  <div class="row">
33
40
  <input
@@ -42,7 +49,7 @@
42
49
 
43
50
  <section class="card">
44
51
  <h2>When this playlist plays</h2>
45
- <p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
52
+ <p class="hint">
46
53
  Leave blank for always on (device local time). All set fields must match.
47
54
  </p>
48
55
  <div class="schedule-grid">
@@ -75,22 +82,6 @@
75
82
  </div>
76
83
  </section>
77
84
 
78
- <section class="card">
79
- <h2>Device actions</h2>
80
- <div class="row">
81
- <button type="button" onclick="getInfo()">Get info</button>
82
- <button type="button" onclick="getCapabilities()">Capabilities</button>
83
- <button type="button" onclick="reboot()">Reboot</button>
84
- <button type="button" class="danger" onclick="clearContent()">Clear content</button>
85
- </div>
86
- </section>
87
-
88
- <section class="publish-bar">
89
- <button type="button" class="primary" style="width: 100%; padding: 0.75rem" onclick="publish()">
90
- Publish playlist
91
- </button>
92
- </section>
93
-
94
85
  <pre id="result" aria-live="polite"></pre>
95
86
  </main>
96
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,191 @@ 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 (hours > 0) parts.push(`${hours}h`);
32
+ if (minutes > 0) parts.push(`${minutes}m`);
33
+ if (parts.length === 0) 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
+ function formatScreenOnlineLabel(device) {
52
+ if (!device.connected || !device.lastOnlineAt) return "Not active";
53
+ const since = new Date(device.lastOnlineAt).getTime();
54
+ if (Number.isNaN(since)) return "Not active";
55
+ return formatDurationMs(Date.now() - since);
15
56
  }
16
57
 
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);
58
+ function updateDeviceUptimeLabels() {
59
+ document.querySelectorAll(".device-card[data-device-id]").forEach((card) => {
60
+ const deviceId = card.dataset.deviceId;
61
+ const device = devicesCache.find((d) => d.deviceId === deviceId);
62
+ if (!device) return;
63
+ const el = card.querySelector(".device-online-time");
64
+ if (el) el.textContent = formatScreenOnlineLabel(device);
65
+ });
66
+ }
67
+
68
+ async function fetchDevices() {
69
+ try {
70
+ const res = await fetch("/devices");
71
+ const data = await res.json();
72
+ if (Array.isArray(data.devices)) {
73
+ devicesCache = data.devices;
74
+ renderDeviceCards();
75
+ }
76
+ } catch (err) {
77
+ showResult({ status: "failed", error: err.message });
78
+ }
79
+ }
80
+
81
+ function renderDeviceCards() {
82
+ const grid = document.getElementById("devicesGrid");
83
+ if (!grid) return;
84
+
85
+ grid.innerHTML = "";
86
+
87
+ if (devicesCache.length === 0) {
88
+ const empty = document.createElement("p");
89
+ empty.className = "devices-empty";
90
+ empty.textContent = "No paired devices yet. Enter a code above.";
91
+ grid.appendChild(empty);
92
+ return;
93
+ }
94
+
95
+ for (const device of devicesCache) {
96
+ const card = document.createElement("article");
97
+ card.className = "device-card";
98
+ card.dataset.deviceId = device.deviceId;
99
+
100
+ const header = document.createElement("div");
101
+ header.className = "device-card-header";
102
+
103
+ const led = document.createElement("span");
104
+ led.className = `status-led ${device.connected ? "status-led--online" : "status-led--offline"}`;
105
+ led.title = device.connected ? "Connected" : "Disconnected";
106
+
107
+ const title = document.createElement("h3");
108
+ title.className = "device-card-title";
109
+ title.textContent = device.deviceName || "Screen";
110
+
111
+ header.appendChild(led);
112
+ header.appendChild(title);
113
+
114
+ const meta = document.createElement("dl");
115
+ meta.className = "device-meta";
116
+
117
+ const rows = [
118
+ ["Device ID", device.deviceId],
119
+ ["System", device.system || device.platform || "—"],
120
+ ["Screen online", formatScreenOnlineLabel(device)],
121
+ ["Last boot", formatDateTimeSeconds(device.lastBootAt)],
122
+ ["Latest content push", formatDateTimeSeconds(device.lastPolicyPushAt)]
123
+ ];
124
+
125
+ for (const [label, value] of rows) {
126
+ const row = document.createElement("div");
127
+ row.className = "device-meta-row";
128
+ const dt = document.createElement("dt");
129
+ dt.textContent = label;
130
+ const dd = document.createElement("dd");
131
+ if (label === "Screen online") {
132
+ dd.className = "device-online-time";
133
+ }
134
+ dd.textContent = value;
135
+ row.appendChild(dt);
136
+ row.appendChild(dd);
137
+ meta.appendChild(row);
138
+ }
139
+
140
+ const actions = document.createElement("div");
141
+ actions.className = "device-card-actions";
142
+
143
+ const publishBtn = document.createElement("button");
144
+ publishBtn.type = "button";
145
+ publishBtn.className = "primary";
146
+ publishBtn.textContent = "Publish";
147
+ publishBtn.addEventListener("click", () => publishToDevice(device.deviceId));
148
+
149
+ const infoBtn = document.createElement("button");
150
+ infoBtn.type = "button";
151
+ infoBtn.textContent = "Info";
152
+ infoBtn.addEventListener("click", () => deviceAction(device.deviceId, "get-info"));
153
+
154
+ const rebootBtn = document.createElement("button");
155
+ rebootBtn.type = "button";
156
+ rebootBtn.textContent = "Reboot";
157
+ rebootBtn.addEventListener("click", () => deviceAction(device.deviceId, "reboot"));
158
+
159
+ const clearBtn = document.createElement("button");
160
+ clearBtn.type = "button";
161
+ clearBtn.textContent = "Clear";
162
+ clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
163
+
164
+ const unpairBtn = document.createElement("button");
165
+ unpairBtn.type = "button";
166
+ unpairBtn.className = "danger";
167
+ unpairBtn.textContent = "Unpair";
168
+ unpairBtn.addEventListener("click", () => unpairDevice(device.deviceId));
169
+
170
+ actions.appendChild(publishBtn);
171
+ actions.appendChild(infoBtn);
172
+ actions.appendChild(rebootBtn);
173
+ actions.appendChild(clearBtn);
174
+ actions.appendChild(unpairBtn);
175
+
176
+ card.appendChild(header);
177
+ card.appendChild(meta);
178
+ card.appendChild(actions);
179
+ grid.appendChild(card);
180
+ }
181
+ }
182
+
183
+ function startDevicePolling() {
184
+ if (devicePollTimer) clearInterval(devicePollTimer);
185
+ if (deviceUptimeTimer) clearInterval(deviceUptimeTimer);
186
+
187
+ void fetchDevices();
188
+ devicePollTimer = setInterval(() => {
189
+ void fetchDevices();
190
+ }, 8000);
191
+
192
+ deviceUptimeTimer = setInterval(updateDeviceUptimeLabels, 60_000);
23
193
  }
24
194
 
25
195
  function showResult(data) {
@@ -104,6 +274,19 @@ function defaultDurationMs(type) {
104
274
  return 10000;
105
275
  }
106
276
 
277
+ function normalizeDurationMs(item) {
278
+ const maxMs = 3600 * 1000;
279
+ const minMs = 1000;
280
+ let ms = Number(item?.durationMs);
281
+ if (!Number.isFinite(ms) || ms < minMs) {
282
+ return defaultDurationMs(item?.type);
283
+ }
284
+ if (ms === 1000000) {
285
+ return defaultDurationMs(item?.type);
286
+ }
287
+ return Math.min(maxMs, ms);
288
+ }
289
+
107
290
  function savePlaylistDraft() {
108
291
  localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
109
292
  }
@@ -142,7 +325,11 @@ function loadPlaylistDraft() {
142
325
  if (!raw) return;
143
326
  const parsed = JSON.parse(raw);
144
327
  if (Array.isArray(parsed)) {
145
- playlistItems = parsed;
328
+ playlistItems = parsed.map((item) => ({
329
+ ...item,
330
+ durationMs: normalizeDurationMs(item)
331
+ }));
332
+ savePlaylistDraft();
146
333
  renderPlaylist();
147
334
  }
148
335
  } catch {
@@ -200,9 +387,10 @@ function renderPlaylist() {
200
387
  durInput.max = "3600";
201
388
  durInput.value = String(Math.round(item.durationMs / 1000));
202
389
  durInput.addEventListener("change", () => {
203
- item.durationMs = Math.max(1000, Number(durInput.value) || 10) * 1000;
390
+ const seconds = Math.min(3600, Math.max(1, Number(durInput.value) || 10));
391
+ item.durationMs = seconds * 1000;
392
+ meta.textContent = `${item.type} · ${seconds}s`;
204
393
  savePlaylistDraft();
205
- renderPlaylist();
206
394
  });
207
395
 
208
396
  const removeBtn = document.createElement("button");
@@ -309,15 +497,40 @@ async function verify() {
309
497
  showResult(data);
310
498
 
311
499
  if (data.deviceId) {
312
- setPanelDeviceId(data.deviceId);
500
+ const codeInput = document.getElementById("code");
501
+ if (codeInput) codeInput.value = "";
502
+ await fetchDevices();
313
503
  }
314
504
  }
315
505
 
316
- async function publish() {
317
- const deviceId = getPanelDeviceId();
506
+ async function unpairDevice(deviceId) {
507
+ if (
508
+ !confirm(
509
+ "Unpair this device? Its card will be removed and the screen will show a new pairing code."
510
+ )
511
+ ) {
512
+ return;
513
+ }
514
+
515
+ const res = await fetch("/pairing/unpair", {
516
+ method: "POST",
517
+ headers: { "Content-Type": "application/json" },
518
+ body: JSON.stringify({ deviceId })
519
+ });
520
+
521
+ const data = await res.json();
522
+ showResult(data);
318
523
 
524
+ if (res.ok) {
525
+ await fetchDevices();
526
+ } else {
527
+ alert(data.error || "Unpair failed");
528
+ }
529
+ }
530
+
531
+ async function publishToDevice(deviceId) {
319
532
  if (!deviceId) {
320
- alert("Pair a device first (enter code and Verify).");
533
+ alert("Unknown device.");
321
534
  return;
322
535
  }
323
536
 
@@ -356,55 +569,26 @@ async function publish() {
356
569
  });
357
570
 
358
571
  const data = await res.json();
359
- showResult({ publish: payload, response: data });
360
- }
572
+ showResult({ deviceId, publish: payload, response: data });
361
573
 
362
- async function getInfo() {
363
- const deviceId = getPanelDeviceId();
364
- if (!deviceId) {
365
- alert("Please pair a device first.");
366
- return;
574
+ if (res.ok) {
575
+ await fetchDevices();
367
576
  }
368
- const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
369
- showResult(await res.json());
370
577
  }
371
578
 
372
- async function getCapabilities() {
373
- const deviceId = getPanelDeviceId();
374
- if (!deviceId) {
375
- alert("Please pair a device first.");
376
- return;
377
- }
378
- const res = await fetch(`/device/${deviceId}/get-capabilities`, { method: "POST" });
379
- showResult(await res.json());
380
- }
579
+ async function deviceAction(deviceId, action) {
580
+ if (!deviceId) return;
381
581
 
382
- async function reboot() {
383
- const deviceId = getPanelDeviceId();
384
- if (!deviceId) {
385
- alert("Please pair a device first.");
386
- return;
387
- }
388
- if (!confirm("Reboot this device?")) return;
389
- const res = await fetch(`/device/${deviceId}/reboot`, { method: "POST" });
390
- showResult(await res.json());
391
- }
582
+ if (action === "reboot" && !confirm("Reboot this device?")) return;
583
+ if (action === "content/clear" && !confirm("Clear content on this device?")) return;
392
584
 
393
- async function clearContent() {
394
- const deviceId = getPanelDeviceId();
395
- if (!deviceId) {
396
- alert("Please pair a device first.");
397
- return;
398
- }
399
- if (!confirm("Clear content on this device?")) return;
400
- const res = await fetch(`/device/${deviceId}/content/clear`, { method: "POST" });
585
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
586
+ method: "POST"
587
+ });
401
588
  showResult(await res.json());
402
589
  }
403
590
 
404
591
  document.addEventListener("DOMContentLoaded", () => {
405
- const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
406
- if (saved) setPanelDeviceId(saved);
407
-
408
592
  const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
409
593
  const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
410
594
  if (savedMediaBase && cmsBaseInput) {
@@ -415,6 +599,7 @@ document.addEventListener("DOMContentLoaded", () => {
415
599
 
416
600
  loadPlaylistDraft();
417
601
  loadScheduleDraft();
602
+ startDevicePolling();
418
603
 
419
604
  document.getElementById("addAssetBtn").addEventListener("click", () => {
420
605
  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;