From d018cb24a37f4673db4e6e6992348b2cecc77b6a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 18 Jun 2026 13:17:50 -0500 Subject: [PATCH] Tizen player: wire up Samsung B2B fleet control (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings device:command (reboot / screen_off / screen_on / shutdown / update / launch) to the Tizen player, at parity with the Android player. Previously app.js only handled device:reload and device:command did nothing on SSSP panels. - NEW tizen/js/device-control.js: self-contained IIFE (window.STDeviceControl = { run, capabilities, backend }). Feature-detects two Samsung surfaces newest-first — webapis.systemcontrol.* (Tizen 6.5/7, synchronous/throws) then b2bapis.b2bcontrol.* (SSSP/Tizen 4, async onSuccess/onError) — and normalises both to Promises, re-probing each call since the APIs can be injected late. run() never rejects; it resolves a uniform { ok, supported, action, note, reload }. Panel power tries setPanelMute (mute ON = backlight OFF) then falls back to setDisplayPanel / setPanelStatus before declaring unsupported. shutdown is honest: SSSP web API has no true power-off, so it mutes the panel and says so. update/reload resolve reload:true. - tizen/js/app.js: keep device:reload; add a device:command handler that calls STDeviceControl.run and reports the outcome via reportCmd (device:log tag=command, which surfaces as dashboard:device-log, plus a structured device:command-result), reloading ~1.2s later when result.reload so the log reaches the server first. reportCapabilities() runs on device:registered so the dashboard sees the backend ("none" on web/consumer TV). - tizen/config.xml: add partner-level b2bcontrol + systemcontrol privileges, with a note that they need a Samsung Partner distributor cert and are ignored (not fatal) on unsigned/URL-Launcher/web/consumer builds. - tizen/index.html: load $WEBAPIS/webapis.js + $B2BAPIS/b2bapis.js before the app scripts (404 harmlessly off-hardware) and device-control.js just before app.js. - tizen/README.md: document the mapping table + partner-signing caveat; update the "Not yet ported" note now that remote control exists. Verified: node --check on both JS files; config.xml well-formed (xmllint). Not yet validated on a real SSSP panel — the control surface only takes effect on a partner-signed .wgt (backend reports "none" on the dev/URL-Launcher build). Co-Authored-By: Claude Opus 4.8 (1M context) --- tizen/README.md | 40 +++++++- tizen/config.xml | 9 ++ tizen/index.html | 6 ++ tizen/js/app.js | 41 ++++++++ tizen/js/device-control.js | 192 +++++++++++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 tizen/js/device-control.js diff --git a/tizen/README.md b/tizen/README.md index bd0cd6d..fc98630 100644 --- a/tizen/README.md +++ b/tizen/README.md @@ -23,12 +23,44 @@ config.xml Tizen TV web-app manifest (privileges, profile, icon) index.html setup / pairing / stage screens css/style.css js/app.js device protocol client (register, pair, heartbeat, state) +js/device-control.js Samsung B2B/system fleet control (device:command) — #125 js/player.js fullscreen playlist renderer js/socket.io.min.js socket.io-client v4.7.5 (bundled) icon.png build-wgt.sh package (signed if Tizen CLI present, else unsigned) ``` +## Fleet control / `device:command` (#125) +The dashboard can send `device:command { type, payload }`; `js/device-control.js` +maps it onto a Samsung panel-control surface and reports the outcome back via +`device:log` (tag `command`, shown live on the device-detail screen) plus a +structured `device:command-result`. If a command needs a content re-pull, the +player reloads ~1.2s after reporting. + +| `type` | Action on a Samsung panel | +|---------------------|------------------------------------------------------------------------| +| `reboot` | `rebootDevice()` | +| `screen_off` | `setPanelMute("ON")` — **mute ON = backlight OFF** (note the inversion)| +| `screen_on` | `setPanelMute("OFF")` | +| `shutdown` | `setPanelMute("ON")` + note: SSSP web API has **no true power-off** | +| `update` | reload to re-pull URL-Launcher content (no in-app OTA) | +| `launch` | no-op (already foreground) | +| `reload` / `refresh`| reload | +| _unknown_ | reported as `unsupported` | + +Two surfaces are feature-detected, newest first: **`webapis.systemcontrol.*`** +(Tizen 6.5/7, synchronous) then **`b2bapis.b2bcontrol.*`** (SSSP/Tizen 4, async). +For panel power, `setPanelMute` is tried first, falling back to `setDisplayPanel` / +`setPanelStatus` on older firmware before declaring it unsupported. `run()` never +throws — it always resolves a uniform `{ ok, supported, action, note, reload }`. + +> **Partner-signing caveat.** The `b2bcontrol` / `systemcontrol` privileges in +> `config.xml` only take effect on a **partner-signed `.wgt` running on a real SSSP +> panel**. On the dev/URL-Launcher/web build (or a consumer TV) the surfaces are +> absent, so every command returns **"unsupported"** and the startup capability log +> reports `backend=none`. Only a partner-signed build on real hardware fully +> validates reboot / panel power. + ## Build ```bash ./build-wgt.sh # -> ScreenTinker.wgt @@ -80,6 +112,8 @@ lives in `~/tizen-studio-data`, password `screentinker`). - Not yet on real Tizen hardware — needs signing + a TV (or URL Launcher). ## Not yet ported (Android player has these; fullscreen single-zone covers most signage) -Multi-zone layouts, video walls (`wall:sync`), screenshots, remote touch/control, -and self-OTA (Tizen apps update via Samsung's store / URL Launcher refresh, not the -Android `PackageInstaller` flow). +Multi-zone layouts, video walls (`wall:sync`), screenshots, and remote touch. +Self-OTA is N/A (Tizen apps update via Samsung's store / URL Launcher refresh, not +the Android `PackageInstaller` flow). **Fleet control (`device:command`: reboot / +screen on-off / shutdown / update / launch) is now wired** — see the section above +(needs a partner-signed build on real SSSP hardware to fully take effect). diff --git a/tizen/config.xml b/tizen/config.xml index c1f1918..b108f8f 100644 --- a/tizen/config.xml +++ b/tizen/config.xml @@ -24,4 +24,13 @@ + + + + diff --git a/tizen/index.html b/tizen/index.html index 1885fb7..6c18194 100644 --- a/tizen/index.html +++ b/tizen/index.html @@ -38,9 +38,15 @@ + + + + diff --git a/tizen/js/app.js b/tizen/js/app.js index 40b0565..a851980 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -143,6 +143,7 @@ deviceId = data.device_id; deviceToken = data.device_token; set(LS.id, deviceId); set(LS.token, deviceToken); startHeartbeat(); + reportCapabilities(); // #125: surface the fleet-control backend to the dashboard if (data.status === 'provisioning') showPairing(); }); @@ -168,6 +169,46 @@ // Optional remote commands the dashboard may send (best-effort) socket.on('device:reload', function () { location.reload(); }); + + // #125: Samsung B2B fleet control — parity with the Android player. The server + // emits device:command { type, payload }; STDeviceControl maps it onto the + // panel's b2bcontrol/systemcontrol surface and reports the outcome back. + socket.on('device:command', function (data) { + var type = (data && data.type) ? String(data.type) : ''; + var payload = (data && data.payload) ? data.payload : null; + if (!type) return; + if (!window.STDeviceControl) { reportCmd('error', type, 'device-control unavailable'); return; } + STDeviceControl.run(type, payload).then(function (res) { + var level = res.ok ? 'info' : (res.supported === false ? 'warn' : 'error'); + reportCmd(level, type, res.note || (res.ok ? 'ok' : 'failed')); + // Delay the reload so the log/result emit reaches the server before we navigate away. + if (res.reload) setTimeout(function () { location.reload(); }, 1200); + }); + }); + } + + // #125: report a command outcome to the dashboard. device:log surfaces live as + // dashboard:device-log on the open device-detail screen; device:command-result is + // a structured echo (harmless if the server doesn't handle it). + function reportCmd(level, type, msg) { + var message = '[' + type + '] ' + msg; + try { + if (socket && deviceId) { + socket.emit('device:log', { device_id: deviceId, tag: 'command', level: level, message: message }); + socket.emit('device:command-result', { device_id: deviceId, type: type, level: level, message: msg }); + } + } catch (e) {} + } + + // #125: log the panel's control surface at startup so the dashboard shows whether + // fleet control is actually wired (backend "none" on web / consumer TV / unsigned). + function reportCapabilities() { + try { + var caps = (window.STDeviceControl && STDeviceControl.capabilities) + ? STDeviceControl.capabilities() : { backend: 'none', reboot: false, panel: false }; + reportCmd('info', 'capabilities', + 'fleet control backend=' + caps.backend + ' reboot=' + caps.reboot + ' panel=' + caps.panel); + } catch (e) {} } function register() { diff --git a/tizen/js/device-control.js b/tizen/js/device-control.js new file mode 100644 index 0000000..6c06c23 --- /dev/null +++ b/tizen/js/device-control.js @@ -0,0 +1,192 @@ +/* ScreenTinker — Tizen/Samsung fleet control (#125). + * + * Wraps the two Samsung panel-control surfaces behind one Promise-based API so the + * player can act on device:command (reboot / screen_off / screen_on / shutdown / + * update / launch) at parity with the Android player. + * + * Surfaces, newest first: + * - Tizen 6.5/7 webapis.systemcontrol.* — synchronous, throws on error + * - SSSP/Tizen 4 b2bapis.b2bcontrol.* — async onSuccess/onError callbacks + * Both are normalised to Promises. Each surface is re-probed on every call because + * the platform can inject these objects late (after the page's first script pass). + * + * IMPORTANT: these B2B/system APIs only take effect on a Samsung panel running a + * .wgt signed with a Samsung *Partner* distributor cert (see config.xml + README). + * On the unsigned dev build, the URL-Launcher/web build, or a consumer TV, the + * surfaces are absent and run() resolves { supported:false } — never throws. + * + * Exposes: window.STDeviceControl = { run, capabilities, backend }. + */ +(function () { + 'use strict'; + + var TAG = 'STDeviceControl'; + function log(msg) { try { console.log('[' + TAG + '] ' + msg); } catch (e) {} } + + // ---- surface probes (fresh each call; APIs can be injected late) ---- + function sysctl() { + try { return (window.webapis && webapis.systemcontrol) ? webapis.systemcontrol : null; } + catch (e) { return null; } + } + function b2b() { + try { return (window.b2bapis && b2bapis.b2bcontrol) ? b2bapis.b2bcontrol : null; } + catch (e) { return null; } + } + + // Active backend, newest first; 'none' on web / consumer TV / unsigned build. + function backend() { + if (sysctl()) return 'systemcontrol'; + if (b2b()) return 'b2bcontrol'; + return 'none'; + } + + function errMsg(e) { + if (!e) return 'unknown error'; + if (typeof e === 'string') return e; + return e.message || e.name || (e.code != null ? ('code ' + e.code) : 'error'); + } + + // Is `method` present on either surface (systemcontrol checked first)? + function methodExists(method) { + var sc = sysctl(); if (sc && typeof sc[method] === 'function') return true; + var bb = b2b(); if (bb && typeof bb[method] === 'function') return true; + return false; + } + + // Call `method` with `args` on whichever surface exposes it, normalised to a + // Promise. systemcontrol is synchronous (resolve on return / reject on throw); + // b2bcontrol appends (onSuccess, onError) callbacks. + function call(method, args) { + args = args || []; + return new Promise(function (resolve, reject) { + var sc = sysctl(); + if (sc && typeof sc[method] === 'function') { + try { resolve(sc[method].apply(sc, args)); } + catch (e) { reject(e); } + return; + } + var bb = b2b(); + if (bb && typeof bb[method] === 'function') { + try { + bb[method].apply(bb, args.concat([ + function (res) { resolve(res); }, + function (err) { reject(err); } + ])); + } catch (e) { reject(e); } + return; + } + reject(new Error('method ' + method + ' not available on backend ' + backend())); + }); + } + + // Panel power across firmware variants. setPanelMute is the modern surface + // (mute ON == backlight OFF — inverted vs `on`); older firmware exposes + // setDisplayPanel / setPanelStatus instead. Picks the first method that EXISTS + // (a present-but-failing method is a real error, surfaced as-is); if none of + // them exist the returned promise rejects with { unsupported:true }. + function panelPower(on) { + var candidates = [ + ['setPanelMute', [on ? 'OFF' : 'ON']], + ['setDisplayPanel', [!!on]], + ['setPanelStatus', [!!on]] + ]; + for (var i = 0; i < candidates.length; i++) { + var method = candidates[i][0]; + if (methodExists(method)) { + return call(method, candidates[i][1]).then((function (m) { + return function (res) { return { method: m, result: res }; }; + })(method)); + } + } + return Promise.reject({ unsupported: true }); + } + + // Uniform result shape — run() always resolves to one of these. + function result(o) { + return { + ok: !!o.ok, + supported: o.supported !== false, + action: o.action || null, + note: o.note || null, + reload: !!o.reload + }; + } + + function panelUnsupportedOr(e, action, unsupportedNote) { + if (e && e.unsupported) { + return result({ ok: false, supported: false, action: action, + note: unsupportedNote || ('no panel-power API on this surface (' + backend() + ')') }); + } + return result({ ok: false, action: action, note: action + ' failed: ' + errMsg(e) }); + } + + // run(type, payload): lowercases type, NEVER rejects, always resolves to a result. + function run(type, payload) { + type = String(type || '').toLowerCase(); + try { + switch (type) { + case 'reboot': + if (!methodExists('rebootDevice')) { + log('reboot: no rebootDevice on backend ' + backend()); + return Promise.resolve(result({ ok: false, supported: false, action: 'reboot', + note: 'no reboot API on this surface (' + backend() + ')' })); + } + return call('rebootDevice', []) + .then(function () { return result({ ok: true, action: 'reboot', note: 'rebootDevice() issued' }); }) + .catch(function (e) { return result({ ok: false, action: 'reboot', note: 'rebootDevice failed: ' + errMsg(e) }); }); + + case 'screen_off': + return panelPower(false) + .then(function (r) { return result({ ok: true, action: 'screen_off', note: 'panel backlight off via ' + r.method }); }) + .catch(function (e) { return panelUnsupportedOr(e, 'screen_off'); }); + + case 'screen_on': + return panelPower(true) + .then(function (r) { return result({ ok: true, action: 'screen_on', note: 'panel backlight on via ' + r.method }); }) + .catch(function (e) { return panelUnsupportedOr(e, 'screen_on'); }); + + case 'shutdown': + // SSSP/Tizen web APIs have no true power-off; the closest honest action is + // muting the panel (backlight off). Report that it's not a real shutdown. + return panelPower(false) + .then(function (r) { return result({ ok: true, action: 'shutdown', + note: 'no true power-off on SSSP web API — panel backlight off via ' + r.method }); }) + .catch(function (e) { return panelUnsupportedOr(e, 'shutdown', + 'no true power-off on SSSP web API and no panel-mute surface available'); }); + + case 'update': + // No in-app OTA for a sideloaded / URL-Launcher build; reloading re-pulls + // the latest URL-Launcher content (app.js performs the reload). + return Promise.resolve(result({ ok: true, action: 'update', reload: true, + note: 'reloading to re-pull URL-Launcher content (no in-app OTA)' })); + + case 'launch': + // Already the foreground app — nothing to launch. + log('launch: no-op (already foreground)'); + return Promise.resolve(result({ ok: true, action: 'launch', note: 'already foreground (no-op)' })); + + case 'reload': + case 'refresh': + return Promise.resolve(result({ ok: true, action: type, reload: true, note: 'reload requested' })); + + default: + log('unknown command: ' + type); + return Promise.resolve(result({ ok: false, supported: false, action: type, note: 'unknown command' })); + } + } catch (e) { + return Promise.resolve(result({ ok: false, action: type, note: 'exception: ' + errMsg(e) })); + } + } + + // Snapshot for a startup log: which surface, and whether reboot/panel are present. + function capabilities() { + return { + backend: backend(), + reboot: methodExists('rebootDevice'), + panel: methodExists('setPanelMute') || methodExists('setDisplayPanel') || methodExists('setPanelStatus') + }; + } + + window.STDeviceControl = { run: run, capabilities: capabilities, backend: backend }; + log('loaded (backend=' + backend() + ')'); +})();