@tomorrowos/sdk 0.2.4 → 0.3.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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/pairing-code.d.ts +8 -0
- package/dist/pairing-code.d.ts.map +1 -0
- package/dist/pairing-code.js +23 -0
- package/dist/playlist-catalog.d.ts +35 -0
- package/dist/playlist-catalog.d.ts.map +1 -0
- package/dist/playlist-catalog.js +133 -0
- package/dist/store/memory-store.d.ts +17 -1
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +57 -0
- package/dist/store/types.d.ts +55 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/tomorrowos.d.ts +19 -1
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +237 -38
- package/package.json +1 -1
- package/templates/cms-starter/policy.example.json +30 -30
- package/templates/cms-starter/public/index.html +43 -15
- package/templates/cms-starter/public/methods.js +455 -368
- package/templates/cms-starter/public/panel.css +458 -329
- package/templates/cms-starter/public/uploads/.gitkeep +1 -0
- package/templates/cms-starter/server.ts +7 -0
package/dist/tomorrowos.js
CHANGED
|
@@ -4,6 +4,8 @@ import fs from "fs/promises";
|
|
|
4
4
|
import http from "http";
|
|
5
5
|
import path from "path";
|
|
6
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
+
import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode } from "./pairing-code.js";
|
|
8
|
+
import { PlaylistCatalog } from "./playlist-catalog.js";
|
|
7
9
|
import { MemoryStore } from "./store/memory-store.js";
|
|
8
10
|
function formatDurationMs(ms) {
|
|
9
11
|
if (!Number.isFinite(ms) || ms < 0)
|
|
@@ -23,8 +25,16 @@ function formatDurationMs(ms) {
|
|
|
23
25
|
parts.push(`${seconds}s`);
|
|
24
26
|
return parts.join(" ");
|
|
25
27
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
+
function resolveHelloDeviceId(msg) {
|
|
29
|
+
const serial = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
|
|
30
|
+
? msg.serialNumber.trim()
|
|
31
|
+
: null;
|
|
32
|
+
if (serial)
|
|
33
|
+
return serial;
|
|
34
|
+
const deviceId = typeof msg.deviceId === "string" && msg.deviceId.trim()
|
|
35
|
+
? msg.deviceId.trim()
|
|
36
|
+
: null;
|
|
37
|
+
return deviceId;
|
|
28
38
|
}
|
|
29
39
|
const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
30
40
|
async function readJsonBody(req) {
|
|
@@ -71,6 +81,7 @@ function parseDevicePath(pathname) {
|
|
|
71
81
|
export class TomorrowOS extends EventEmitter {
|
|
72
82
|
brand;
|
|
73
83
|
store;
|
|
84
|
+
playlists;
|
|
74
85
|
devices = new Map();
|
|
75
86
|
pendingDeviceMeta = new Map();
|
|
76
87
|
httpServer = null;
|
|
@@ -81,42 +92,81 @@ export class TomorrowOS extends EventEmitter {
|
|
|
81
92
|
super();
|
|
82
93
|
this.brand = options.brand;
|
|
83
94
|
this.store = options.store ?? new MemoryStore();
|
|
95
|
+
this.playlists = new PlaylistCatalog(this.store);
|
|
84
96
|
}
|
|
85
|
-
/**
|
|
97
|
+
/** Push all device assignments using latest playlist definitions (e.g. after reboot). */
|
|
98
|
+
async pushLatestPolicyToDevice(deviceId) {
|
|
99
|
+
const id = String(deviceId || "").trim();
|
|
100
|
+
const assignments = await this.store.getDeviceAssignments(id);
|
|
101
|
+
if (assignments.length === 0)
|
|
102
|
+
return { pushed: false };
|
|
103
|
+
const built = await this.playlists.buildPolicyForDevice(id, { useLatest: true });
|
|
104
|
+
const ws = this.devices.get(id);
|
|
105
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
106
|
+
return { pushed: false, policy: built.policy };
|
|
107
|
+
}
|
|
108
|
+
await this.sendDeviceCommand(id, "device.content.setPolicy", {
|
|
109
|
+
policy: built.policy
|
|
110
|
+
});
|
|
111
|
+
await this.recordPolicyPush(id);
|
|
112
|
+
return { pushed: true, policy: built.policy };
|
|
113
|
+
}
|
|
114
|
+
async sendDeviceCommand(deviceId, method, params) {
|
|
115
|
+
const ws = this.devices.get(deviceId);
|
|
116
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
117
|
+
throw Object.assign(new Error("Device not connected"), { code: "DEVICE_OFFLINE" });
|
|
118
|
+
}
|
|
119
|
+
return this.sendCommandToSocket(ws, deviceId, method, params);
|
|
120
|
+
}
|
|
121
|
+
/** Verify a 6-character alphanumeric pairing code (POST /pairing/verify). */
|
|
86
122
|
async pairingVerify(code) {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
const err = new Error("Invalid
|
|
123
|
+
const normalized = normalizePairingCode(code);
|
|
124
|
+
if (!isValidPairingCodeFormat(normalized)) {
|
|
125
|
+
const err = new Error("Invalid pairing code format");
|
|
126
|
+
err.code = "PAIRING_INVALID";
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
let deviceId;
|
|
130
|
+
const registry = await this.store.getDeviceRegistryByCode(normalized);
|
|
131
|
+
if (registry) {
|
|
132
|
+
deviceId = registry.deviceId;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const pending = await this.store.getPendingCode(normalized);
|
|
136
|
+
deviceId = pending?.deviceId;
|
|
137
|
+
}
|
|
138
|
+
if (!deviceId) {
|
|
139
|
+
const err = new Error("Invalid or unknown pairing code");
|
|
90
140
|
err.code = "PAIRING_INVALID";
|
|
91
141
|
throw err;
|
|
92
142
|
}
|
|
93
143
|
const pairingToken = randomBytes(32).toString("hex");
|
|
94
144
|
const pairedAt = new Date().toISOString();
|
|
95
|
-
const meta = this.pendingDeviceMeta.get(
|
|
145
|
+
const meta = this.pendingDeviceMeta.get(deviceId);
|
|
96
146
|
const now = pairedAt;
|
|
97
|
-
await this.store.setPairedDevice(
|
|
147
|
+
await this.store.setPairedDevice(deviceId, {
|
|
98
148
|
pairingToken,
|
|
99
149
|
pairedAt,
|
|
100
150
|
deviceName: meta?.deviceName,
|
|
101
151
|
platform: meta?.platform,
|
|
102
152
|
system: meta?.system,
|
|
103
153
|
lastBootAt: meta?.bootedAt ?? now,
|
|
104
|
-
lastOnlineAt: this.isDeviceConnected(
|
|
105
|
-
lastOfflineAt: this.isDeviceConnected(
|
|
154
|
+
lastOnlineAt: this.isDeviceConnected(deviceId) ? now : undefined,
|
|
155
|
+
lastOfflineAt: this.isDeviceConnected(deviceId) ? undefined : now
|
|
106
156
|
});
|
|
107
|
-
await this.store.deletePendingCode(
|
|
108
|
-
const ws = this.devices.get(
|
|
157
|
+
await this.store.deletePendingCode(normalized);
|
|
158
|
+
const ws = this.devices.get(deviceId);
|
|
109
159
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
110
160
|
ws.send(JSON.stringify({
|
|
111
161
|
type: "pairing.verified",
|
|
112
162
|
method: "tomorrowos.pairing.verify",
|
|
113
|
-
deviceId
|
|
163
|
+
deviceId,
|
|
114
164
|
pairingToken
|
|
115
165
|
}));
|
|
116
|
-
void this.refreshPairedDeviceInfo(
|
|
166
|
+
void this.refreshPairedDeviceInfo(deviceId);
|
|
117
167
|
}
|
|
118
|
-
this.emit("device.paired", { deviceId
|
|
119
|
-
return { deviceId
|
|
168
|
+
this.emit("device.paired", { deviceId });
|
|
169
|
+
return { deviceId };
|
|
120
170
|
}
|
|
121
171
|
/** Remove pairing for a device and notify it over WebSocket if connected. */
|
|
122
172
|
async pairingUnpair(deviceId) {
|
|
@@ -149,8 +199,10 @@ export class TomorrowOS extends EventEmitter {
|
|
|
149
199
|
async listDevices() {
|
|
150
200
|
const entries = await this.store.listPairedDevices();
|
|
151
201
|
const now = Date.now();
|
|
152
|
-
return entries.map(({ deviceId, record }) => {
|
|
202
|
+
return Promise.all(entries.map(async ({ deviceId, record }) => {
|
|
153
203
|
const connected = this.isDeviceConnected(deviceId);
|
|
204
|
+
const reg = await this.store.getDeviceRegistry(deviceId);
|
|
205
|
+
const assignments = await this.store.getDeviceAssignments(deviceId);
|
|
154
206
|
let screenOnlineActive = false;
|
|
155
207
|
let screenOnlineLabel = "Not active";
|
|
156
208
|
if (connected && record.lastBootAt) {
|
|
@@ -163,10 +215,18 @@ export class TomorrowOS extends EventEmitter {
|
|
|
163
215
|
const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
|
|
164
216
|
return {
|
|
165
217
|
deviceId,
|
|
218
|
+
pairingCode: reg?.permanentPairingCode ?? null,
|
|
219
|
+
publishedPlaylists: assignments.map((a) => ({
|
|
220
|
+
playlistId: a.playlistId,
|
|
221
|
+
name: a.snapshot.name,
|
|
222
|
+
version: a.publishedVersion,
|
|
223
|
+
publishedAt: a.publishedAt
|
|
224
|
+
})),
|
|
166
225
|
connected,
|
|
167
226
|
deviceName: record.deviceName ?? null,
|
|
168
227
|
platform: record.platform ?? null,
|
|
169
228
|
system: record.system ?? null,
|
|
229
|
+
serialNumber: reg?.serialNumber ?? deviceId,
|
|
170
230
|
pairedAt: record.pairedAt,
|
|
171
231
|
lastBootAt: record.lastBootAt ?? null,
|
|
172
232
|
lastOnlineAt: record.lastOnlineAt ?? null,
|
|
@@ -176,7 +236,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
176
236
|
screenOnlineLabel,
|
|
177
237
|
screenOnlineSince
|
|
178
238
|
};
|
|
179
|
-
});
|
|
239
|
+
}));
|
|
180
240
|
}
|
|
181
241
|
isDeviceConnected(deviceId) {
|
|
182
242
|
const ws = this.devices.get(deviceId);
|
|
@@ -188,9 +248,37 @@ export class TomorrowOS extends EventEmitter {
|
|
|
188
248
|
deviceName: typeof msg.deviceName === "string" ? msg.deviceName : undefined,
|
|
189
249
|
system: typeof msg.system === "string" ? msg.system : undefined,
|
|
190
250
|
bootedAt: typeof msg.bootedAt === "string" ? msg.bootedAt : undefined,
|
|
191
|
-
playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined
|
|
251
|
+
playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined,
|
|
252
|
+
serialNumber: typeof msg.serialNumber === "string" ? msg.serialNumber : deviceId
|
|
192
253
|
});
|
|
193
254
|
}
|
|
255
|
+
async getOrCreatePermanentPairingCode(deviceId, serialNumber) {
|
|
256
|
+
const existing = await this.store.getDeviceRegistry(deviceId);
|
|
257
|
+
if (existing?.permanentPairingCode) {
|
|
258
|
+
await this.store.setDeviceRegistry(deviceId, {
|
|
259
|
+
...existing,
|
|
260
|
+
serialNumber: serialNumber ?? existing.serialNumber ?? deviceId,
|
|
261
|
+
lastHelloAt: Date.now()
|
|
262
|
+
});
|
|
263
|
+
return existing.permanentPairingCode;
|
|
264
|
+
}
|
|
265
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
266
|
+
const code = generateRandomPairingCode();
|
|
267
|
+
const collision = await this.store.getDeviceRegistryByCode(code);
|
|
268
|
+
if (collision && collision.deviceId !== deviceId)
|
|
269
|
+
continue;
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
await this.store.setDeviceRegistry(deviceId, {
|
|
272
|
+
permanentPairingCode: code,
|
|
273
|
+
codeCreatedAt: now,
|
|
274
|
+
serialNumber: serialNumber ?? deviceId,
|
|
275
|
+
firstSeenAt: now,
|
|
276
|
+
lastHelloAt: now
|
|
277
|
+
});
|
|
278
|
+
return code;
|
|
279
|
+
}
|
|
280
|
+
throw new Error("Failed to allocate unique pairing code");
|
|
281
|
+
}
|
|
194
282
|
async mergePairedRecord(deviceId, patch) {
|
|
195
283
|
const existing = await this.store.getPairedDevice(deviceId);
|
|
196
284
|
if (!existing)
|
|
@@ -496,6 +584,105 @@ export class TomorrowOS extends EventEmitter {
|
|
|
496
584
|
sendJson(res, 200, { status: "success", devices });
|
|
497
585
|
return;
|
|
498
586
|
}
|
|
587
|
+
if (req.method === "GET" && pathname === "/playlists") {
|
|
588
|
+
const playlists = await this.playlists.listPlaylists();
|
|
589
|
+
sendJson(res, 200, { status: "success", playlists });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (req.method === "POST" && pathname === "/playlists") {
|
|
593
|
+
const body = (await readJsonBody(req));
|
|
594
|
+
try {
|
|
595
|
+
const saved = await this.playlists.savePlaylist({
|
|
596
|
+
id: typeof body.id === "string" ? body.id : undefined,
|
|
597
|
+
name: String(body.name ?? ""),
|
|
598
|
+
schedule: body.schedule && typeof body.schedule === "object"
|
|
599
|
+
? body.schedule
|
|
600
|
+
: undefined,
|
|
601
|
+
items: Array.isArray(body.items) ? body.items : []
|
|
602
|
+
});
|
|
603
|
+
sendJson(res, 200, { status: "success", playlist: saved });
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
const msg = e instanceof Error ? e.message : "Save failed";
|
|
607
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const playlistDelete = /^\/playlists\/([^/]+)$/.exec(pathname);
|
|
612
|
+
if (req.method === "DELETE" && playlistDelete) {
|
|
613
|
+
const playlistId = decodeURIComponent(playlistDelete[1]);
|
|
614
|
+
try {
|
|
615
|
+
const retired = await this.playlists.retirePlaylist(playlistId);
|
|
616
|
+
sendJson(res, 200, { status: "success", playlist: retired });
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
const msg = e instanceof Error ? e.message : "Delete failed";
|
|
620
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const deviceAssignmentsGet = /^\/device\/([^/]+)\/assignments$/.exec(pathname);
|
|
625
|
+
if (req.method === "GET" && deviceAssignmentsGet) {
|
|
626
|
+
const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
|
|
627
|
+
const assignments = await this.playlists.getDeviceAssignments(deviceId);
|
|
628
|
+
sendJson(res, 200, { status: "success", assignments });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const deviceAssignmentsPost = /^\/device\/([^/]+)\/assignments$/.exec(pathname);
|
|
632
|
+
if (req.method === "POST" && deviceAssignmentsPost) {
|
|
633
|
+
const deviceId = decodeURIComponent(deviceAssignmentsPost[1]);
|
|
634
|
+
const body = (await readJsonBody(req));
|
|
635
|
+
const ws = this.devices.get(deviceId);
|
|
636
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
637
|
+
sendJson(res, 404, { status: "failed", error: "Device not connected" });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
let built;
|
|
642
|
+
if (body.useLatest === true) {
|
|
643
|
+
built = await this.playlists.buildPolicyForDevice(deviceId, {
|
|
644
|
+
useLatest: true
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
const ids = Array.isArray(body.playlistIds) ? body.playlistIds : [];
|
|
649
|
+
built = await this.playlists.publishPlaylistsToDevice(deviceId, ids);
|
|
650
|
+
}
|
|
651
|
+
const result = await this.sendDeviceCommand(deviceId, "device.content.setPolicy", {
|
|
652
|
+
policy: built.policy
|
|
653
|
+
});
|
|
654
|
+
await this.recordPolicyPush(deviceId);
|
|
655
|
+
sendJson(res, 200, { status: "success", policy: built.policy, result });
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
const msg = e instanceof Error ? e.message : "Publish failed";
|
|
659
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
660
|
+
}
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const deviceAssignmentDelete = /^\/device\/([^/]+)\/assignments\/([^/]+)$/.exec(pathname);
|
|
664
|
+
if (req.method === "DELETE" && deviceAssignmentDelete) {
|
|
665
|
+
const deviceId = decodeURIComponent(deviceAssignmentDelete[1]);
|
|
666
|
+
const playlistId = decodeURIComponent(deviceAssignmentDelete[2]);
|
|
667
|
+
const ws = this.devices.get(deviceId);
|
|
668
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
669
|
+
sendJson(res, 404, { status: "failed", error: "Device not connected" });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const built = await this.playlists.removePlaylistFromDevice(deviceId, playlistId);
|
|
674
|
+
const result = await this.sendDeviceCommand(deviceId, "device.content.setPolicy", {
|
|
675
|
+
policy: built.policy
|
|
676
|
+
});
|
|
677
|
+
await this.recordPolicyPush(deviceId);
|
|
678
|
+
sendJson(res, 200, { status: "success", policy: built.policy, result });
|
|
679
|
+
}
|
|
680
|
+
catch (e) {
|
|
681
|
+
const msg = e instanceof Error ? e.message : "Remove failed";
|
|
682
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
499
686
|
if (req.method === "POST") {
|
|
500
687
|
const parsed = parseDevicePath(pathname);
|
|
501
688
|
if (parsed) {
|
|
@@ -593,25 +780,34 @@ export class TomorrowOS extends EventEmitter {
|
|
|
593
780
|
}
|
|
594
781
|
const type = msg.type;
|
|
595
782
|
if (type === "device.hello") {
|
|
596
|
-
const deviceId =
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
783
|
+
const deviceId = resolveHelloDeviceId(msg) ?? randomUUID();
|
|
784
|
+
const serialNumber = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
|
|
785
|
+
? msg.serialNumber.trim()
|
|
786
|
+
: deviceId;
|
|
787
|
+
void (async () => {
|
|
788
|
+
try {
|
|
789
|
+
const code = await this.getOrCreatePermanentPairingCode(deviceId, serialNumber);
|
|
790
|
+
this.captureHelloMeta(deviceId, msg);
|
|
791
|
+
this.devices.set(deviceId, ws);
|
|
792
|
+
void this.store.setPendingCode(code, {
|
|
793
|
+
deviceId,
|
|
794
|
+
createdAt: Date.now()
|
|
795
|
+
});
|
|
796
|
+
ws.deviceId = deviceId;
|
|
797
|
+
ws.send(JSON.stringify({
|
|
798
|
+
type: "pairing.code",
|
|
799
|
+
method: "tomorrowos.pairing.createCode",
|
|
800
|
+
code,
|
|
801
|
+
deviceId,
|
|
802
|
+
serialNumber
|
|
803
|
+
}));
|
|
804
|
+
this.sendBrandSnapshot(ws);
|
|
805
|
+
this.emit("device.online", { deviceId });
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
console.error("[TomorrowOS] device.hello failed:", err);
|
|
809
|
+
}
|
|
810
|
+
})();
|
|
615
811
|
return;
|
|
616
812
|
}
|
|
617
813
|
if (type === "device.resume") {
|
|
@@ -638,6 +834,9 @@ export class TomorrowOS extends EventEmitter {
|
|
|
638
834
|
this.sendBrandSnapshot(ws);
|
|
639
835
|
void this.refreshPairedDeviceInfo(deviceId);
|
|
640
836
|
this.emit("device.online", { deviceId });
|
|
837
|
+
void this.pushLatestPolicyToDevice(deviceId).catch((err) => {
|
|
838
|
+
console.error("[TomorrowOS] pushLatestPolicy on resume failed:", err);
|
|
839
|
+
});
|
|
641
840
|
})();
|
|
642
841
|
}
|
|
643
842
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,30 +1,30 @@
|
|
|
1
|
-
{
|
|
2
|
-
"policy": {
|
|
3
|
-
"playlists": [
|
|
4
|
-
{
|
|
5
|
-
"id": "weekday-promo",
|
|
6
|
-
"name": "Weekday promo",
|
|
7
|
-
"schedule": {
|
|
8
|
-
"startDate": "2026-05-01",
|
|
9
|
-
"endDate": "2026-05-31",
|
|
10
|
-
"daysOfWeek": [1, 2, 3, 4, 5],
|
|
11
|
-
"start": "09:00",
|
|
12
|
-
"end": "17:00"
|
|
13
|
-
},
|
|
14
|
-
"items": [
|
|
15
|
-
{
|
|
16
|
-
"url": "https://example.com/hero.jpg",
|
|
17
|
-
"type": "image",
|
|
18
|
-
"durationMs": 10000
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
"url": "https://example.com/spot.mp4",
|
|
22
|
-
"type": "video",
|
|
23
|
-
"durationMs": 30000
|
|
24
|
-
}
|
|
25
|
-
]
|
|
26
|
-
}
|
|
27
|
-
],
|
|
28
|
-
"fallback": { "type": "brand" }
|
|
29
|
-
}
|
|
30
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"policy": {
|
|
3
|
+
"playlists": [
|
|
4
|
+
{
|
|
5
|
+
"id": "weekday-promo",
|
|
6
|
+
"name": "Weekday promo",
|
|
7
|
+
"schedule": {
|
|
8
|
+
"startDate": "2026-05-01",
|
|
9
|
+
"endDate": "2026-05-31",
|
|
10
|
+
"daysOfWeek": [1, 2, 3, 4, 5],
|
|
11
|
+
"start": "09:00",
|
|
12
|
+
"end": "17:00"
|
|
13
|
+
},
|
|
14
|
+
"items": [
|
|
15
|
+
{
|
|
16
|
+
"url": "https://example.com/hero.jpg",
|
|
17
|
+
"type": "image",
|
|
18
|
+
"durationMs": 10000
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"url": "https://example.com/spot.mp4",
|
|
22
|
+
"type": "video",
|
|
23
|
+
"durationMs": 30000
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"fallback": { "type": "brand" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -9,16 +9,24 @@
|
|
|
9
9
|
<body>
|
|
10
10
|
<header class="app-header">
|
|
11
11
|
<h1>TomorrowOS Control Panel</h1>
|
|
12
|
-
<p>
|
|
12
|
+
<p>Create playlists, save with confirm, then publish selected playlists per device.</p>
|
|
13
13
|
</header>
|
|
14
14
|
|
|
15
15
|
<div class="layout">
|
|
16
|
+
<aside class="panel-playlist-nav">
|
|
17
|
+
<div class="playlist-nav-header">
|
|
18
|
+
<h2>Playlists</h2>
|
|
19
|
+
<button type="button" class="primary" id="newPlaylistBtn" title="New playlist">+</button>
|
|
20
|
+
</div>
|
|
21
|
+
<ul id="playlistCatalog" class="playlist-catalog"></ul>
|
|
22
|
+
</aside>
|
|
23
|
+
|
|
16
24
|
<main class="panel-main">
|
|
17
25
|
<section class="card">
|
|
18
26
|
<h2>Pair device</h2>
|
|
19
|
-
<p class="hint">Enter the 6-
|
|
27
|
+
<p class="hint">Enter the 6-character code on the screen (A–Z or 0–9).</p>
|
|
20
28
|
<div class="row">
|
|
21
|
-
<input id="code" maxlength="6" placeholder="
|
|
29
|
+
<input id="code" maxlength="6" placeholder="e.g. A3K9Z1" autocapitalize="characters" />
|
|
22
30
|
<button type="button" onclick="verify()">Verify</button>
|
|
23
31
|
</div>
|
|
24
32
|
</section>
|
|
@@ -26,17 +34,14 @@
|
|
|
26
34
|
<section class="card" id="pairedDevicesSection">
|
|
27
35
|
<h2>Paired devices</h2>
|
|
28
36
|
<div id="devicesGrid" class="devices-grid">
|
|
29
|
-
<p class="devices-empty" id="devicesEmpty">No paired devices yet
|
|
37
|
+
<p class="devices-empty" id="devicesEmpty">No paired devices yet.</p>
|
|
30
38
|
</div>
|
|
31
39
|
</section>
|
|
32
40
|
|
|
33
41
|
<section class="card" id="cmsUrlSection">
|
|
34
42
|
<h2>CMS URL for screens</h2>
|
|
35
43
|
<p class="hint">
|
|
36
|
-
<strong>Local development only.</strong>
|
|
37
|
-
TVs are on the same LAN (e.g. <code>http://192.168.1.105:3000</code> — same machine as your
|
|
38
|
-
<code>ws://</code> address, not <code>localhost</code>). On hosted CMS (Replit, etc.) you
|
|
39
|
-
normally do <strong>not</strong> need to change this.
|
|
44
|
+
<strong>Local development only.</strong> LAN URL for TVs (not localhost).
|
|
40
45
|
</p>
|
|
41
46
|
<div class="row">
|
|
42
47
|
<input
|
|
@@ -49,11 +54,16 @@
|
|
|
49
54
|
</div>
|
|
50
55
|
</section>
|
|
51
56
|
|
|
52
|
-
<section class="card">
|
|
53
|
-
<h2>
|
|
54
|
-
<p class="hint">
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
<section class="card" id="playlistEditorSection">
|
|
58
|
+
<h2 id="editorTitle">Playlist editor</h2>
|
|
59
|
+
<p class="hint">Save confirms changes. Names must be unique. Schedule applies to this playlist only.</p>
|
|
60
|
+
<label class="field-label">
|
|
61
|
+
Name
|
|
62
|
+
<input type="text" id="playlistName" placeholder="e.g. Weekday promo" />
|
|
63
|
+
</label>
|
|
64
|
+
|
|
65
|
+
<h3 class="subheading">When this playlist plays</h3>
|
|
66
|
+
<p class="hint">Leave blank for always on (device local time).</p>
|
|
57
67
|
<div class="schedule-grid">
|
|
58
68
|
<label>
|
|
59
69
|
Start date
|
|
@@ -82,6 +92,11 @@
|
|
|
82
92
|
<label><input type="checkbox" class="day-checkbox" value="6" /> Sat</label>
|
|
83
93
|
</div>
|
|
84
94
|
</div>
|
|
95
|
+
|
|
96
|
+
<div class="editor-actions">
|
|
97
|
+
<button type="button" class="primary" id="savePlaylistBtn">Save playlist</button>
|
|
98
|
+
<button type="button" class="danger" id="deletePlaylistBtn">Delete playlist</button>
|
|
99
|
+
</div>
|
|
85
100
|
</section>
|
|
86
101
|
|
|
87
102
|
<pre id="result" aria-live="polite"></pre>
|
|
@@ -89,7 +104,7 @@
|
|
|
89
104
|
|
|
90
105
|
<aside class="panel-playlist">
|
|
91
106
|
<div class="playlist-header">
|
|
92
|
-
<h2>
|
|
107
|
+
<h2>Assets</h2>
|
|
93
108
|
<button type="button" class="primary" id="addAssetBtn" title="Add image or video">+</button>
|
|
94
109
|
</div>
|
|
95
110
|
<input
|
|
@@ -99,11 +114,24 @@
|
|
|
99
114
|
accept="image/*,video/*,.wgt,.zip"
|
|
100
115
|
/>
|
|
101
116
|
<ul id="playlistList" class="playlist-list">
|
|
102
|
-
<li class="playlist-empty" id="playlistEmpty">
|
|
117
|
+
<li class="playlist-empty" id="playlistEmpty">Select or create a playlist, then add assets.</li>
|
|
103
118
|
</ul>
|
|
104
119
|
</aside>
|
|
105
120
|
</div>
|
|
106
121
|
|
|
122
|
+
<div id="publishModal" class="modal hidden" role="dialog" aria-modal="true">
|
|
123
|
+
<div class="modal-backdrop" data-close-modal="1"></div>
|
|
124
|
+
<div class="modal-card">
|
|
125
|
+
<h2>Publish to device</h2>
|
|
126
|
+
<p class="hint" id="publishModalHint">Select playlists to deploy. Updates apply on publish; reboot pulls latest saved versions.</p>
|
|
127
|
+
<div id="publishChecklist" class="publish-checklist"></div>
|
|
128
|
+
<div class="modal-actions">
|
|
129
|
+
<button type="button" id="publishConfirmBtn" class="primary">Publish selected</button>
|
|
130
|
+
<button type="button" data-close-modal="1">Cancel</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
107
135
|
<script src="./methods.js" defer></script>
|
|
108
136
|
</body>
|
|
109
137
|
</html>
|