@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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/store/memory-store.d.ts +2 -1
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +6 -0
- package/dist/store/types.d.ts +12 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/tomorrowos.d.ts +26 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +166 -8
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +13 -23
- package/templates/cms-starter/public/methods.js +220 -69
- package/templates/cms-starter/public/panel.css +97 -0
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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;
|
|
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
|
}
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -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;
|
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -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,
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
-
|
|
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
|
|
30
|
-
HTTP(S) base URL your
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">")
|
|
19
|
+
.replace(/"/g, """);
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
516
|
+
const codeInput = document.getElementById("code");
|
|
517
|
+
if (codeInput) codeInput.value = "";
|
|
518
|
+
await fetchDevices();
|
|
331
519
|
}
|
|
332
520
|
}
|
|
333
521
|
|
|
334
|
-
async function
|
|
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?
|
|
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
|
-
|
|
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
|
|
368
|
-
const deviceId = getPanelDeviceId();
|
|
369
|
-
|
|
547
|
+
async function publishToDevice(deviceId) {
|
|
370
548
|
if (!deviceId) {
|
|
371
|
-
alert("
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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;
|