@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 +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 +24 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +165 -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 +202 -68
- 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,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;
|
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,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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|
|
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,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
|
-
|
|
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 (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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
500
|
+
const codeInput = document.getElementById("code");
|
|
501
|
+
if (codeInput) codeInput.value = "";
|
|
502
|
+
await fetchDevices();
|
|
331
503
|
}
|
|
332
504
|
}
|
|
333
505
|
|
|
334
|
-
async function
|
|
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?
|
|
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
|
-
|
|
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
|
|
368
|
-
const deviceId = getPanelDeviceId();
|
|
369
|
-
|
|
531
|
+
async function publishToDevice(deviceId) {
|
|
370
532
|
if (!deviceId) {
|
|
371
|
-
alert("
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
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
|
-
}
|
|
579
|
+
async function deviceAction(deviceId, action) {
|
|
580
|
+
if (!deviceId) return;
|
|
432
581
|
|
|
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
|
-
}
|
|
582
|
+
if (action === "reboot" && !confirm("Reboot this device?")) return;
|
|
583
|
+
if (action === "content/clear" && !confirm("Clear content on this device?")) return;
|
|
443
584
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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;
|