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