Tizen player: wire up Samsung B2B fleet control (#125)

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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-18 13:17:50 -05:00
parent 10726fde42
commit d018cb24a3
5 changed files with 285 additions and 3 deletions

View file

@ -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).

View file

@ -24,4 +24,13 @@
<tizen:privilege name="http://tizen.org/privilege/application.launch"/>
<tizen:privilege name="http://tizen.org/privilege/display"/>
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
<!-- #125: Samsung B2B fleet control (reboot / panel power via b2bcontrol /
systemcontrol). These are PARTNER-level privileges: they only take effect
when the .wgt is signed with a Samsung Partner distributor certificate on a
real SSSP panel. On unsigned / URL-Launcher / web / consumer-TV builds they
are ignored (not fatal) — the APIs are simply absent and device-control.js
reports "unsupported". -->
<tizen:privilege name="http://developer.samsung.com/privilege/b2bcontrol"/>
<tizen:privilege name="http://developer.samsung.com/privilege/systemcontrol"/>
</widget>

View file

@ -38,9 +38,15 @@
<!-- Tiny on-screen status (offline / errors), auto-hides -->
<div id="toast" class="toast hidden"></div>
<!-- #125: Samsung device-API bridges. $WEBAPIS / $B2BAPIS are resolved by the
Tizen platform at runtime; off-hardware (browser / URL Launcher) these 404
harmlessly and the surfaces are simply absent. -->
<script src="$WEBAPIS/webapis/webapis.js"></script>
<script src="$B2BAPIS/b2bapis/b2bapis.js"></script>
<script src="js/socket.io.min.js"></script>
<script src="js/schedule-eval.js"></script>
<script src="js/player.js"></script>
<script src="js/device-control.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View file

@ -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() {

192
tizen/js/device-control.js Normal file
View file

@ -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() + ')');
})();