mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
#123 already shipped a placeholder device:command handler (#121/#122): screen_off was a black overlay, reboot/shutdown a toast, update a "re-install" toast. This replaces that with the real control surface from #125, reconciled into the single handler #123 introduced (rather than landing a second, competing handler). - NEW tizen/js/device-control.js: window.STDeviceControl = { run, capabilities, backend }. Feature-detects webapis.systemcontrol.* (Tizen 6.5/7, sync/throws) then b2bapis.b2bcontrol.* (SSSP/Tizen 4, async), normalises both to Promises, re-probes each call. run() never rejects; resolves { ok, supported, action, note, reload }. Panel power: setPanelMute (mute ON = backlight OFF) -> setDisplayPanel/setPanelStatus fallback. reboot -> rebootDevice(); shutdown mutes the panel and notes SSSP has no true power-off; update/reload -> reload:true. - tizen/js/app.js: device:command now calls STDeviceControl.run and reports the outcome via reportCmd (device:log tag=command -> dashboard:device-log, plus a structured device:command-result), reloading ~1.2s later on result.reload. screen_off falls back to the existing black overlay (showScreenOff) when no B2B surface exists; screen_on/launch still clear the overlay + keepAwake. Dropped the now-dead tryPowerControl. reportCapabilities() runs on device:registered so the dashboard sees the backend ("none" on web/URL-Launcher/consumer TV). - tizen/config.xml: partner-level b2bcontrol + systemcontrol privileges (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 before app.js. - tizen/README.md: rewrote the remote-control table for real B2B control + a partner-signing caveat; added device-control.js to the file list. Supersedes PR #126 (feat/tizen-device-command-125), which targeted main unaware that this branch already had a device:command handler. 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:
parent
9c4b48800f
commit
e2ff8f47b7
|
|
@ -31,6 +31,7 @@ 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
|
||||
|
|
@ -87,31 +88,38 @@ lives in `~/tizen-studio-data`, password `screentinker`).
|
|||
- **Runtime**: loads + renders in Chromium with no JS errors (setup screen verified).
|
||||
- Not yet on real Tizen hardware — needs signing + a TV (or URL Launcher).
|
||||
|
||||
## Remote control & preview (#120 / #121)
|
||||
The Tizen player now listens for the same dashboard events as the web/Android player.
|
||||
What it can actually do depends on what a **sideloaded web app** is allowed to do on
|
||||
the TV runtime:
|
||||
## Remote control & preview (#120 / #121 / #125)
|
||||
The Tizen player listens for the same dashboard events as the web/Android player.
|
||||
`device:command` is handled by `js/device-control.js`, which drives the real Samsung
|
||||
fleet-control surface (`webapis.systemcontrol` on Tizen 6.5/7, else `b2bapis.b2bcontrol`
|
||||
on SSSP/Tizen 4) and reports each outcome back via `device:log` (tag `command`, shown
|
||||
live on the device-detail screen) plus a structured `device:command-result`:
|
||||
|
||||
| Command (`device:command` type) | Tizen behaviour |
|
||||
|-----------------------------------|-----------------------------------------------------------|
|
||||
| `refresh` | `location.reload()` |
|
||||
| `launch` / `screen_on` | clears the screen-off overlay + re-requests screen-awake |
|
||||
| `screen_off` | black full-screen overlay (content keeps running behind) |
|
||||
| `update` | toast: must re-install the `.wgt` (see **Updates** below) |
|
||||
| `reboot` / `shutdown` | MDM-only — not reachable from a sideloaded app (toast) |
|
||||
|-----------------------------------|------------------------------------------------------------------------|
|
||||
| `refresh` / `reload` | `location.reload()` |
|
||||
| `launch` / `screen_on` | clears the screen-off overlay + re-asserts wake; `setPanelMute("OFF")` when the B2B surface is present |
|
||||
| `screen_off` | `setPanelMute("ON")` (backlight off) on a B2B panel; **black overlay fallback** otherwise |
|
||||
| `update` | reload to re-pull URL-Launcher content (no in-app OTA — see **Updates**) |
|
||||
| `reboot` | `rebootDevice()` on a B2B panel; `unsupported` otherwise |
|
||||
| `shutdown` | `setPanelMute("ON")` + note (SSSP web API has no true power-off) |
|
||||
| _unknown_ | reported as `unsupported` |
|
||||
| `device:screenshot-request` | best-effort capture (see note) |
|
||||
| `device:remote-start` / `-stop` | start/stop ~1 fps preview streaming |
|
||||
|
||||
> **Partner-signing caveat (#125):** the `b2bcontrol` / `systemcontrol` privileges in
|
||||
> `config.xml` only take effect on a **partner-signed `.wgt` on a real SSSP panel**. On
|
||||
> the dev/URL-Launcher/web build (or a consumer TV) those surfaces are absent, so reboot
|
||||
> returns `unsupported`, `screen_off` uses the black overlay, and the startup capability
|
||||
> log reports `backend=none`. Only a partner-signed build on real hardware fully
|
||||
> validates reboot / panel power.
|
||||
|
||||
> **Screenshot/preview note:** the TV decodes `<video>` onto a hardware overlay plane
|
||||
> and plays YouTube in a cross-origin `<iframe>`, neither of which can be read back into
|
||||
> a `<canvas>`. So **images capture for real; video/YouTube fall back to a status card**
|
||||
> (device + timestamp). The dashboard preview shows a truthful frame rather than a dead
|
||||
> button. Full-fidelity video preview isn't feasible on the sideloaded Tizen runtime.
|
||||
|
||||
> **`screen_off`** uses an overlay, not a real panel power-off — a sideloaded app has no
|
||||
> clean panel-power API. On B2B/MDM (SSSP) firmware, true power and `reboot`/`shutdown`
|
||||
> go through Samsung's device-management channel, not this app.
|
||||
|
||||
## Updates (#122)
|
||||
There is **no in-app OTA** for a sideloaded, signed `.wgt`. Updating a screen means
|
||||
**re-building and re-sideloading** the `.wgt` (path B above), or — on Samsung B2B
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@
|
|||
authenticated = true; // #118: this socket may now send post-register events
|
||||
clearToast(); // #118: drop any stale "Not authenticated…" banner
|
||||
startHeartbeat();
|
||||
reportCapabilities(); // #125: surface the fleet-control backend to the dashboard
|
||||
if (data.status === 'provisioning') showPairing();
|
||||
});
|
||||
|
||||
|
|
@ -195,36 +196,41 @@
|
|||
|
||||
socket.on('device:playlist-update', onPlaylist);
|
||||
|
||||
// ---- remote control from the dashboard (#120 / #121) ----
|
||||
// ---- remote control from the dashboard (#120 / #121 / #125) ----
|
||||
// Mirror the web/Android player. The server emits device:command with the set in
|
||||
// server/routes/device-groups.js (ALLOWED_COMMANDS) plus 'refresh', and the
|
||||
// screenshot/remote events below. (The old device:reload listener was dead — the
|
||||
// server never emits it — so 'refresh' replaces it.)
|
||||
//
|
||||
// #125: reboot / screen power / shutdown now go through STDeviceControl, which
|
||||
// drives the real Samsung b2bcontrol/systemcontrol surface on a partner-signed
|
||||
// panel. Where that surface is absent (web / URL-Launcher / consumer TV), it
|
||||
// resolves { supported:false } and we fall back to the local black overlay for
|
||||
// screen_off so the command still does something visible.
|
||||
socket.on('device:command', function (data) {
|
||||
switch (data && data.type) {
|
||||
case 'refresh':
|
||||
location.reload();
|
||||
break;
|
||||
case 'launch':
|
||||
case 'screen_on':
|
||||
clearScreenOff();
|
||||
keepAwake();
|
||||
break;
|
||||
case 'screen_off':
|
||||
var type = (data && data.type) ? String(data.type).toLowerCase() : '';
|
||||
var payload = (data && data.payload) ? data.payload : null;
|
||||
if (!type) return;
|
||||
|
||||
// "Wake" intents always clear any black overlay and re-assert screen-awake,
|
||||
// independent of (and in addition to) the panel API.
|
||||
if (type === 'screen_on' || type === 'launch') { clearScreenOff(); keepAwake(); }
|
||||
|
||||
if (!window.STDeviceControl) { reportCmd('error', type, 'device-control unavailable'); return; }
|
||||
STDeviceControl.run(type, payload).then(function (res) {
|
||||
var note = res.note;
|
||||
// No real panel-power surface: keep the pre-#125 behaviour — a black overlay
|
||||
// (content keeps running behind it) — so screen_off isn't a silent no-op.
|
||||
if (type === 'screen_off' && res.supported === false) {
|
||||
showScreenOff();
|
||||
break;
|
||||
case 'update':
|
||||
// #122: a sideloaded, signed .wgt has no in-app OTA path. Surface it rather
|
||||
// than dropping it silently — real updates are a re-sideload / B2B-MDM push.
|
||||
toast('Update must be re-installed (.wgt sideload / MDM)', false);
|
||||
break;
|
||||
case 'reboot':
|
||||
case 'shutdown':
|
||||
tryPowerControl(data.type);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
res = { ok: true, supported: true, reload: false };
|
||||
note = 'no panel API — black overlay fallback';
|
||||
}
|
||||
var level = res.ok ? 'info' : (res.supported === false ? 'warn' : 'error');
|
||||
reportCmd(level, type, note || (res.ok ? 'ok' : 'failed'));
|
||||
// Delay the reload so the log/result emit reaches the server first.
|
||||
if (res.reload) setTimeout(function () { location.reload(); }, 1200);
|
||||
});
|
||||
});
|
||||
|
||||
// #120: dashboard preview — single shot and start/stop streaming.
|
||||
|
|
@ -279,11 +285,28 @@
|
|||
var o = document.getElementById('screenOffOverlay');
|
||||
if (o && o.parentNode) o.parentNode.removeChild(o);
|
||||
}
|
||||
// #121: reboot/shutdown need privileged B2B/MDM control that a sideloaded web app
|
||||
// does not have. Signage firmware exposes these via the Samsung B2B (b2bapis) API,
|
||||
// whose methods vary by firmware — so document it rather than failing silently.
|
||||
function tryPowerControl(type) {
|
||||
toast(type + ' is MDM-only on Tizen (see tizen/README.md)', false);
|
||||
// #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) {}
|
||||
}
|
||||
|
||||
// #120: best-effort dashboard preview. The Tizen TV runtime decodes <video> onto a
|
||||
|
|
|
|||
192
tizen/js/device-control.js
Normal file
192
tizen/js/device-control.js
Normal 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() + ')');
|
||||
})();
|
||||
Loading…
Reference in a new issue