@tomorrowos/sdk 0.1.10 → 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.
@@ -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,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;
@@ -54,6 +69,15 @@ export declare class TomorrowOS extends EventEmitter {
54
69
  notified: boolean;
55
70
  }>;
56
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;
57
81
  device(deviceId: string): {
58
82
  sendCommand<T = unknown>(method: string, params?: Record<string, unknown>): Promise<{
59
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;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,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,6 +114,7 @@ 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 };
@@ -99,6 +128,7 @@ export class TomorrowOS extends EventEmitter {
99
128
  throw err;
100
129
  }
101
130
  await this.store.deletePairedDevice(id);
131
+ this.pendingDeviceMeta.delete(id);
102
132
  const ws = this.devices.get(id);
103
133
  let notified = false;
104
134
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -116,6 +146,118 @@ export class TomorrowOS extends EventEmitter {
116
146
  verify: (code) => this.pairingVerify(code),
117
147
  unpair: (deviceId) => this.pairingUnpair(deviceId)
118
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
+ }
119
261
  device(deviceId) {
120
262
  const self = this;
121
263
  return {
@@ -327,6 +469,11 @@ export class TomorrowOS extends EventEmitter {
327
469
  }
328
470
  return;
329
471
  }
472
+ if (req.method === "GET" && pathname === "/devices") {
473
+ const devices = await this.listDevices();
474
+ sendJson(res, 200, { status: "success", devices });
475
+ return;
476
+ }
330
477
  if (req.method === "POST") {
331
478
  const parsed = parseDevicePath(pathname);
332
479
  if (parsed) {
@@ -390,6 +537,9 @@ export class TomorrowOS extends EventEmitter {
390
537
  });
391
538
  return;
392
539
  }
540
+ if (action === "content/set-policy" && result.status === "success") {
541
+ await this.recordPolicyPush(deviceId);
542
+ }
393
543
  sendJson(res, 200, { status: result.status, data: result.data });
394
544
  }
395
545
  catch (e) {
@@ -421,6 +571,7 @@ export class TomorrowOS extends EventEmitter {
421
571
  ? msg.deviceId
422
572
  : randomUUID();
423
573
  const code = createSixDigitCode();
574
+ this.captureHelloMeta(deviceId, msg);
424
575
  this.devices.set(deviceId, ws);
425
576
  void this.store.setPendingCode(code, {
426
577
  deviceId,
@@ -449,27 +600,33 @@ export class TomorrowOS extends EventEmitter {
449
600
  }));
450
601
  return;
451
602
  }
603
+ this.captureHelloMeta(deviceId, msg);
452
604
  this.devices.set(deviceId, ws);
453
605
  ws.deviceId = deviceId;
606
+ await this.touchPairedOnline(deviceId, msg);
454
607
  ws.send(JSON.stringify({
455
608
  type: "device.resumed",
456
609
  method: "tomorrowos.pairing.resume",
457
610
  deviceId
458
611
  }));
459
612
  this.sendBrandSnapshot(ws);
613
+ void this.refreshPairedDeviceInfo(deviceId);
460
614
  this.emit("device.online", { deviceId });
461
615
  })();
462
616
  }
463
617
  });
464
618
  ws.on("close", () => {
465
619
  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
- }
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
+ });
473
630
  });
474
631
  }
475
632
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.1.10",
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.10",
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.10"
13
+ "@tomorrowos/sdk": "^0.2.0"
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,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);
56
+ }
57
+
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
+ }
15
181
  }
16
182
 
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);
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) {
@@ -327,20 +497,16 @@ async function verify() {
327
497
  showResult(data);
328
498
 
329
499
  if (data.deviceId) {
330
- setPanelDeviceId(data.deviceId);
500
+ const codeInput = document.getElementById("code");
501
+ if (codeInput) codeInput.value = "";
502
+ await fetchDevices();
331
503
  }
332
504
  }
333
505
 
334
- async function unpair() {
335
- const deviceId = getPanelDeviceId();
336
- if (!deviceId) {
337
- alert("No paired device in this panel.");
338
- return;
339
- }
340
-
506
+ async function unpairDevice(deviceId) {
341
507
  if (
342
508
  !confirm(
343
- "Unpair this device? The screen will show a new 6-digit code and this panel will forget the device."
509
+ "Unpair this device? Its card will be removed and the screen will show a new pairing code."
344
510
  )
345
511
  ) {
346
512
  return;
@@ -356,19 +522,15 @@ async function unpair() {
356
522
  showResult(data);
357
523
 
358
524
  if (res.ok) {
359
- setPanelDeviceId("");
360
- const codeInput = document.getElementById("code");
361
- if (codeInput) codeInput.value = "";
525
+ await fetchDevices();
362
526
  } else {
363
527
  alert(data.error || "Unpair failed");
364
528
  }
365
529
  }
366
530
 
367
- async function publish() {
368
- const deviceId = getPanelDeviceId();
369
-
531
+ async function publishToDevice(deviceId) {
370
532
  if (!deviceId) {
371
- alert("Pair a device first (enter code and Verify).");
533
+ alert("Unknown device.");
372
534
  return;
373
535
  }
374
536
 
@@ -407,55 +569,26 @@ async function publish() {
407
569
  });
408
570
 
409
571
  const data = await res.json();
410
- showResult({ publish: payload, response: data });
411
- }
572
+ showResult({ deviceId, publish: payload, response: data });
412
573
 
413
- async function getInfo() {
414
- const deviceId = getPanelDeviceId();
415
- if (!deviceId) {
416
- alert("Please pair a device first.");
417
- return;
574
+ if (res.ok) {
575
+ await fetchDevices();
418
576
  }
419
- const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
420
- showResult(await res.json());
421
577
  }
422
578
 
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
- }
579
+ async function deviceAction(deviceId, action) {
580
+ if (!deviceId) return;
432
581
 
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
- }
582
+ if (action === "reboot" && !confirm("Reboot this device?")) return;
583
+ if (action === "content/clear" && !confirm("Clear content on this device?")) return;
443
584
 
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" });
585
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
586
+ method: "POST"
587
+ });
452
588
  showResult(await res.json());
453
589
  }
454
590
 
455
591
  document.addEventListener("DOMContentLoaded", () => {
456
- const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
457
- if (saved) setPanelDeviceId(saved);
458
-
459
592
  const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
460
593
  const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
461
594
  if (savedMediaBase && cmsBaseInput) {
@@ -466,6 +599,7 @@ document.addEventListener("DOMContentLoaded", () => {
466
599
 
467
600
  loadPlaylistDraft();
468
601
  loadScheduleDraft();
602
+ startDevicePolling();
469
603
 
470
604
  document.getElementById("addAssetBtn").addEventListener("click", () => {
471
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;