From 9face2fdd4f7acd0f45d15af4d718bbd3c32e7d2 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 7 Aug 2026 08:06:45 -0500 Subject: [PATCH] Show a panel's IPv6, and size the pairing code to the screen it is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two field-reported gaps, unrelated except that both are about being able to read something off a screen. A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN. DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no address at all and the dashboard rendered a dash for a screen that was perfectly reachable. It now reports both stacks in their own fields: a dual-stack panel genuinely has two addresses and either may be the one you need, so collapsing them into one column would make it mean "whichever interface enumerated first". Link-local (fe80::/10) is deliberately excluded. Every interface has one, they tend to enumerate first, and none can be dialled without also knowing the zone index — so admitting them would fill the field with a string nobody can paste anywhere and hide the address that works. Any %iface suffix is trimmed for the same reason. The 45-char cap the writer already applied is exactly the longest legitimate IPv6 text form, so it needed no change. The dashboard card renders only when a panel actually has a v6 address, rather than showing an empty row to the overwhelmingly v4 fleet. THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST. Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the 4K wall it was installed on, which is where signage actually goes. What has to stay constant is ANGULAR size, so the root font size is now viewport-proportional and everything on those screens is a rem against it. The code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for bit what it renders today, so nothing changes for the existing fleet — 144px at 4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px viewport the root computes to 13.0473px, which is 0.926vmin to four decimals. vmin, not vw, because portrait-mounted panels are common here and vw would render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's preview iframe stays legible instead of microscopic and an ultrawide does not get silly. Applied to the web player (which BrightSign also runs) and to Tizen, where a 1920x1080 logical viewport makes it arithmetically identical to the values it replaces — the point being the panels where it is not. A test asserts the scaling cannot reach playback content: the whole safety argument is that only the chrome uses rem, and a stage or zone rule adopting it would start resizing CONTENT, which is a worse bug than the one being fixed. Android is untouched — its pairing code already autosizes within a dp-scaled layout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS --- .../player/telemetry/DeviceInfo.kt | 39 ++++++++ docs/openapi.yaml | 14 ++- frontend/js/i18n/de.js | 1 + frontend/js/i18n/en.js | 1 + frontend/js/i18n/es.js | 1 + frontend/js/i18n/fr.js | 1 + frontend/js/i18n/it.js | 1 + frontend/js/i18n/pt.js | 1 + frontend/js/views/device-detail.js | 12 +++ server/db/database.js | 6 ++ server/player/index.html | 64 ++++++++----- server/routes/devices.js | 2 +- server/test/openapi-contract.test.js | 7 +- server/test/player-screen-scaling.test.js | 92 +++++++++++++++++++ server/test/telemetry-ipv6.test.js | 92 +++++++++++++++++++ server/ws/deviceSocket.js | 7 +- tizen/css/style.css | 58 +++++++----- 17 files changed, 351 insertions(+), 48 deletions(-) create mode 100644 server/test/player-screen-scaling.test.js create mode 100644 server/test/telemetry-ipv6.test.js diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt index 03bbde2..d99cd45 100644 --- a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt @@ -35,6 +35,10 @@ class DeviceInfo(private val context: Context) { // ISP's address as their screen's IP. Needs no permission — read straight off the // interfaces, so it works on Ethernet panels too, not just Wi-Fi. put("local_ip", getLocalIp() ?: JSONObject.NULL) + // Both stacks, not one: getLocalIp() filters to Inet4Address, so a v6-only panel + // reported NOTHING and the dashboard showed a dash for a screen that had a perfectly + // good address. A dual-stack panel now shows both. + put("local_ip6", getLocalIp6() ?: JSONObject.NULL) put("wifi_rssi", getWifiRSSI()) put("uptime_seconds", getUptimeSeconds()) // #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator) @@ -221,6 +225,41 @@ class DeviceInfo(private val context: Context) { found } catch (e: Throwable) { null } + /** + * The panel's own IPv6 address, reported alongside the v4 one rather than instead of it — + * a dual-stack screen has both and an operator may need either. + * + * Deliberately NOT link-local (fe80::/10). Every interface has one, they are the addresses + * most likely to be enumerated first, and none of them can be dialled without also knowing + * the zone index — so putting one in the dashboard would fill the field with a string that + * cannot be pasted anywhere useful and hide the address that can. A global or unique-local + * address is the one someone reaching the panel on site actually needs. + * + * The scope check also drops multicast and the unspecified address; what survives is a + * routable unicast address. `hostAddress` can carry a %iface suffix on some builds, so it is + * trimmed — the field is for humans and for pasting into a browser. + */ + private fun getLocalIp6(): String? = try { + var found: String? = null + val ifaces = java.net.NetworkInterface.getNetworkInterfaces() + while (ifaces != null && ifaces.hasMoreElements() && found == null) { + val iface = ifaces.nextElement() + if (!iface.isUp || iface.isLoopback) continue + val addrs = iface.inetAddresses + while (addrs.hasMoreElements()) { + val addr = addrs.nextElement() + if (addr is java.net.Inet6Address && + !addr.isLoopbackAddress && !addr.isLinkLocalAddress && + !addr.isAnyLocalAddress && !addr.isMulticastAddress + ) { + found = addr.hostAddress?.substringBefore('%') + break + } + } + } + found + } catch (e: Throwable) { null } + @Suppress("DEPRECATION") private fun getWifiRSSI(): Int { return try { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 05251e2..588cbcb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -122,9 +122,19 @@ components: local_ip: type: [string, "null"] description: | - The device's **own address on its local network** (e.g. `192.168.1.42`), as + The device's **own IPv4 address on its local network** (e.g. `192.168.1.42`), as reported by the player itself. This is the one to use to reach a panel directly on - site. Null on players that do not report it, or where the platform withholds it. + site. Null on players that do not report it, where the platform withholds it, or on + a panel with no IPv4 address at all — see `local_ip6`. + local_ip6: + type: [string, "null"] + description: | + The device's **own IPv6 address on its local network**, reported alongside + `local_ip` rather than instead of it: a dual-stack panel has both and either may be + the one you need. Link-local addresses (`fe80::/10`) are deliberately excluded — + every interface has one and none can be reached without also knowing the zone + index, so this carries a global or unique-local address or nothing. Null on players + that do not report it and on IPv4-only panels. # --- Latest telemetry -------------------------------------------------------------- # Flattened from the most recent telemetry report. All null for a device that has diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 8429af2..d02bc5f 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -260,6 +260,7 @@ export default { 'device.info.status': 'Status', 'device.info.ip_address': 'IP-Adresse', 'device.info.local_ip': 'Lokale IP', + 'device.info.local_ip6': 'Lokale IPv6', 'device.info.wifi_needs_location': 'Standortberechtigung erforderlich', 'device.info.battery': 'Akku', 'device.info.storage': 'Speicher', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 821c15d..70458bc 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -476,6 +476,7 @@ export default { 'device.info.status': 'Status', 'device.info.ip_address': 'IP Address', 'device.info.local_ip': 'Local IP', + 'device.info.local_ip6': 'Local IPv6', 'device.info.wifi_needs_location': 'Needs location permission', 'device.info.battery': 'Battery', 'device.info.storage': 'Storage', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 9a6da03..c13b6d9 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -290,6 +290,7 @@ export default { 'device.info.status': 'Estado', 'device.info.ip_address': 'Dirección IP', 'device.info.local_ip': 'IP local', + 'device.info.local_ip6': 'IPv6 local', 'device.info.wifi_needs_location': 'Requiere permiso de ubicación', 'device.info.battery': 'Batería', 'device.info.storage': 'Almacenamiento', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 438f36c..45c4452 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -260,6 +260,7 @@ export default { 'device.info.status': 'Statut', 'device.info.ip_address': 'Adresse IP', 'device.info.local_ip': 'IP locale', + 'device.info.local_ip6': 'IPv6 locale', 'device.info.wifi_needs_location': 'Autorisation de localisation requise', 'device.info.battery': 'Batterie', 'device.info.storage': 'Stockage', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index 091c2d9..4fa9f80 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -276,6 +276,7 @@ export default { 'device.info.status': 'Stato', 'device.info.ip_address': 'Indirizzo IP', 'device.info.local_ip': 'IP locale', + 'device.info.local_ip6': 'IPv6 locale', 'device.info.wifi_needs_location': 'Richiede permesso di posizione', 'device.info.battery': 'Batteria', 'device.info.storage': 'Archiviazione', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index e7a90d5..1213f46 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -260,6 +260,7 @@ export default { 'device.info.status': 'Status', 'device.info.ip_address': 'Endereço IP', 'device.info.local_ip': 'IP local', + 'device.info.local_ip6': 'IPv6 local', 'device.info.wifi_needs_location': 'Requer permissão de localização', 'device.info.battery': 'Bateria', 'device.info.storage': 'Armazenamento', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 32e6fba..70f848e 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -366,6 +366,15 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.info.local_ip')}
${device.local_ip || '--'}
+ ${device.local_ip6 ? ` +
+ +
${t('device.info.local_ip6')}
+
${device.local_ip6}
+
` : ''} ${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.battery')}
@@ -2070,6 +2079,9 @@ function updateTelemetryDisplay(telemetry) { if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) })); if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid)); if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip); + // update() no-ops when the card is absent, which is the case for a v4-only panel — a screen that + // acquires a v6 address mid-session picks the card up on the next full render, not this path. + if (telemetry.local_ip6) update('telLocalIp6', telemetry.local_ip6); if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm'); if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds)); if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) })); diff --git a/server/db/database.js b/server/db/database.js index 48f89fb..f0b4dde 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -382,6 +382,12 @@ const migrations = [ // the PUBLIC address the server sees the connection arrive from — both are useful and they are // not the same thing. A customer reading the public IP as "my screen's IP" prompted this. "ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT", + // ...and its IPv6 one, in its own column rather than sharing the above. The player's collector + // filtered to Inet4Address, so a v6-only panel reported no address at all and the dashboard + // showed a dash for a screen that had a perfectly reachable address. Separate columns because a + // dual-stack panel genuinely has both and an operator may need either — collapsing them would + // make the field mean "whichever we happened to enumerate first". + "ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT", // Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because // only some hardware exposes one — Android and the browser players send nothing and must keep // reading as "no sensor" rather than "0 degrees", which is why every read site treats null as diff --git a/server/player/index.html b/server/player/index.html index 35b61fc..fb11b79 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -126,6 +126,26 @@ ScreenTinker Player @@ -841,8 +863,8 @@ if (document.getElementById('enableAudioPrompt')) return; const ov = document.createElement('div'); ov.id = 'enableAudioPrompt'; - ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)'; - ov.innerHTML = '🔇Tap to enable audio'; + ov.style.cssText = 'position:fixed;bottom:2.4rem;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:1.2rem 2.2rem;border-radius:0.8rem;cursor:pointer;z-index:10000;font-size:1.4rem;display:flex;gap:1rem;align-items:center;box-shadow:0 0.4rem 1.6rem rgba(0,0,0,0.4)'; + ov.innerHTML = '🔇Tap to enable audio'; ov.addEventListener('click', () => { unlockAudioContext(); tryUnmuteLeader(); @@ -1030,9 +1052,9 @@ const tapOverlay = document.createElement('div'); tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer'; tapOverlay.innerHTML = ` -

ScreenTinker

-

Tap anywhere to start

-

Audio requires user interaction

+

ScreenTinker

+

Tap anywhere to start

+

Audio requires user interaction

`; tapOverlay.onclick = () => { unlockAudio(); diff --git a/server/routes/devices.js b/server/routes/devices.js index 44e30e3..4367d70 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -23,7 +23,7 @@ router.get('/', (req, res) => { const devices = db.prepare(` SELECT d.*, t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb, - t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, + t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, t.local_ip6, t.cpu_usage, s.filepath as screenshot_path, s.captured_at as screenshot_at, u.email as owner_email, u.name as owner_name diff --git a/server/test/openapi-contract.test.js b/server/test/openapi-contract.test.js index 185de59..de1c786 100644 --- a/server/test/openapi-contract.test.js +++ b/server/test/openapi-contract.test.js @@ -78,7 +78,7 @@ test('openapi: the spec version tracks the shipped release', () => { // GET /devices, so both must be documented and must not be described interchangeably. test('openapi: a device documents its WAN and LAN addresses distinctly', () => { const props = spec.components.schemas.Device.properties; - for (const field of ['ip_address', 'local_ip']) { + for (const field of ['ip_address', 'local_ip', 'local_ip6']) { assert.ok(props[field], `Device.${field} is returned by GET /devices but is not documented`); assert.ok( props[field].type.includes('null'), @@ -88,6 +88,11 @@ test('openapi: a device documents its WAN and LAN addresses distinctly', () => { } assert.match(props.ip_address.description, /WAN|public/i); assert.match(props.local_ip.description, /local network|LAN/i); + assert.match(props.local_ip6.description, /local network|LAN/i); + // The two LAN fields are a pair, not alternatives — a dual-stack panel reports both, so the + // spec must not let an integrator read one as a fallback for the other. + assert.match(props.local_ip.description, /IPv4/i); + assert.match(props.local_ip6.description, /IPv6/i); }); // "permission" is a sentinel, not a network name: Android 10+ withholds the SSID without a diff --git a/server/test/player-screen-scaling.test.js b/server/test/player-screen-scaling.test.js new file mode 100644 index 0000000..b8ca526 --- /dev/null +++ b/server/test/player-screen-scaling.test.js @@ -0,0 +1,92 @@ +'use strict'; + +/* + * The pairing code has to be readable on the panel it is displayed on. + * + * A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a + * sixteenth on 8K. Every size on the player's pre-playback screens was a hard-coded pixel value, + * so the 72px pairing code that fills a 1080p screen became a smudge on a 4K wall — reported from + * the field, on exactly the screens signage gets installed on. + * + * The fix is a viewport-proportional root font size with everything on those screens expressed in + * rem, so the code holds the same ANGULAR size at any panel resolution. These tests pin the two + * properties that actually matter: nothing on those screens is left in px, and the scaling cannot + * leak into playback content. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +const SURFACES = [ + { name: 'web player', file: 'server/player/index.html' }, + { name: 'tizen player', file: 'tizen/css/style.css' }, +]; + +for (const { name, file } of SURFACES) { + test(`${name}: the root font size is viewport-proportional and clamped at both ends`, () => { + const src = read(file); + const m = src.match(/html\s*\{\s*font-size:\s*clamp\(\s*([\d.]+)px\s*,\s*([\d.]+)vmin\s*,\s*([\d.]+)px\s*\)/); + assert.ok(m, `${file} must set a clamped, vmin-based root font size`); + const [, min, vmin, max] = m.map(Number); + + // vmin, not vw: a portrait-mounted panel is as common as a landscape one, and vw would render + // a 1080x1920 screen at half size. + assert.ok(vmin > 0, 'the middle term must actually scale with the viewport'); + // 1rem should land on 10px at a 1080-tall viewport, so the rem values read as "px at 1080p" + // and a reviewer can check them against the design at a glance. + assert.ok(Math.abs(vmin * 1080 / 100 - 10) < 0.1, `1rem must be ~10px at 1080p, got ${vmin * 1080 / 100}px`); + // The floor keeps a dashboard preview iframe and a laptop window legible rather than + // microscopic; the ceiling stops an ultrawide from getting silly. + assert.ok(min >= 6 && min <= 10, `floor should keep small viewports readable, got ${min}px`); + assert.ok(max >= 32, `ceiling should not cap a genuine 8K panel too early, got ${max}px`); + }); +} + +test('web player: the pairing code scales, and is still the biggest thing on the screen', () => { + const src = read('server/player/index.html'); + const rule = src.slice(src.indexOf('.pairing-code {'), src.indexOf('.pairing-hint')); + const size = rule.match(/font-size:\s*([\d.]+)rem/); + assert.ok(size, 'the pairing code must be sized in rem, not px'); + assert.ok(Number(size[1]) >= 7, 'the code is what someone squints at from across a room'); + assert.match(rule, /letter-spacing:\s*[\d.]+rem/, 'letter-spacing must scale with it or the digits collide'); + + const h1 = src.match(/#setupScreen h1 \{ font-size: ([\d.]+)rem/); + assert.ok(h1 && Number(size[1]) > Number(h1[1]), 'the code must outrank the product name'); +}); + +test('tizen player: the pairing code scales too', () => { + const src = read('tizen/css/style.css'); + const rule = src.slice(src.indexOf('.code {'), src.indexOf('.hint {')); + assert.match(rule, /font-size:\s*[\d.]+rem/, 'the Tizen pairing code must be sized in rem'); + assert.match(rule, /letter-spacing:\s*[\d.]+rem/); +}); + +test('no pre-playback screen is left in hard-coded px', () => { + const src = read('server/player/index.html'); + // Everything from the setup screen through the status overlay. A px value surviving in here is + // one element that stays put while the rest of the screen grows around it. + for (const selector of ['#setupScreen h1', '#setupScreen .subtitle', '.pairing-hint', '.status-msg', '#statusOverlay h2', '#statusOverlay p']) { + const at = src.indexOf(selector + ' {'); + assert.ok(at > 0, `${selector} not found`); + const rule = src.slice(at, src.indexOf('}', at)); + const px = rule.match(/font-size:\s*[\d.]+px/); + assert.equal(px, null, `${selector} still has a hard-coded font-size — it will not scale`); + } +}); + +test('the scaling cannot reach playback content', () => { + const src = read('server/player/index.html'); + // The whole safety argument for moving the root font size is that only the pre-playback chrome + // uses rem. If a stage/zone/PiP rule starts using rem, resizing a panel would start resizing + // CONTENT, which is a different and much worse bug than the one being fixed here. + for (const selector of ['#stage', '.zone', '#pip']) { + const at = src.indexOf(selector + ' {'); + if (at < 0) continue; + const rule = src.slice(at, src.indexOf('}', at)); + assert.ok(!/[\d.]rem/.test(rule), `${selector} must not use rem — content sizing must stay independent`); + } +}); diff --git a/server/test/telemetry-ipv6.test.js b/server/test/telemetry-ipv6.test.js new file mode 100644 index 0000000..37b3324 --- /dev/null +++ b/server/test/telemetry-ipv6.test.js @@ -0,0 +1,92 @@ +'use strict'; + +/* + * A panel's own IPv6 address has to survive the trip from the player to the dashboard. + * + * It never used to exist: the Android collector filtered to Inet4Address, so a v6-only screen + * reported no address at all and the dashboard rendered a dash for a panel that was perfectly + * reachable. The fix is two columns rather than one, because a dual-stack panel genuinely has + * both and collapsing them would make the field mean "whichever interface enumerated first". + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +test('the Android collector reports BOTH stacks, not whichever it finds first', () => { + const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt'); + assert.match(src, /put\("local_ip6"/, 'telemetry must carry local_ip6'); + assert.match(src, /getLocalIp6/, 'there must be a v6 collector'); + // The v4 one keeps its filter — this is an addition, not a replacement. A v4 panel must keep + // reporting exactly what it reported before. + assert.match(src, /addr is java\.net\.Inet4Address/, 'local_ip stays IPv4-only'); +}); + +test('link-local v6 is excluded — it cannot be dialled without a zone index', () => { + const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt'); + const fn = src.slice(src.indexOf('private fun getLocalIp6'), src.indexOf('private fun getWifiRSSI')); + assert.ok(fn.length > 0, 'getLocalIp6 not found'); + for (const guard of ['isLinkLocalAddress', 'isLoopbackAddress', 'isAnyLocalAddress', 'isMulticastAddress']) { + assert.ok(fn.includes(guard), `getLocalIp6 must skip ${guard} addresses`); + } + // Every interface has an fe80:: address and it is usually enumerated first, so without this the + // field would fill up with strings nobody can paste anywhere and hide the useful address. + assert.match(fn, /substringBefore\('%'\)/, 'a %iface suffix must be trimmed before it reaches the UI'); +}); + +test('the column exists, holds a full-length v6 address, and is separate from local_ip', () => { + const db = new Database(':memory:'); + db.exec('CREATE TABLE device_telemetry (id INTEGER PRIMARY KEY, device_id TEXT, local_ip TEXT)'); + // The same statement the migration list runs. + db.exec('ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT'); + + const v6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + db.prepare('INSERT INTO device_telemetry (device_id, local_ip, local_ip6) VALUES (?, ?, ?)') + .run('d1', '192.168.1.42', v6); + const row = db.prepare('SELECT local_ip, local_ip6 FROM device_telemetry WHERE device_id = ?').get('d1'); + assert.equal(row.local_ip, '192.168.1.42', 'the v4 address is untouched by the addition'); + assert.equal(row.local_ip6, v6); + + // 45 characters is the longest legitimate IPv6 text form (IPv4-mapped, ::ffff:255.255.255.255). + // The write path caps at 45, so a real address must never be truncated by it. + assert.ok('::ffff:255.255.255.255'.length <= 45); + assert.ok(v6.length <= 45, 'a full uncompressed v6 address fits the cap the writer applies'); + db.close(); +}); + +test('the migration is registered, and does not replace the v4 column', () => { + const src = read('server/db/database.js'); + assert.match(src, /ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT/); + assert.match(src, /ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT/); +}); + +test('the server stores what the player sends, capped like its neighbour', () => { + const src = read('server/ws/deviceSocket.js'); + assert.match(src, /local_ip, local_ip6, temperature_c/, 'the INSERT must name the new column'); + assert.match( + src, + /typeof telemetry\.local_ip6 === 'string' \? telemetry\.local_ip6\.trim\(\)\.slice\(0, 45\)/, + 'device-supplied text headed for the dashboard must be trimmed and capped', + ); +}); + +test('both addresses reach the dashboard, and the v6 card only appears when there is one', () => { + const api = read('server/routes/devices.js'); + assert.match(api, /t\.local_ip, t\.local_ip6/, 'the device list must select the new column'); + + const ui = read('frontend/js/views/device-detail.js'); + assert.match(ui, /device\.local_ip6 \?/, 'the card must be conditional — no empty row for a v4-only fleet'); + assert.match(ui, /telLocalIp6/); + assert.match(ui, /device\.info\.local_ip6/, 'the label must go through i18n, not be hardcoded'); +}); + +test('every locale has the label — a missing key renders as the raw key', () => { + for (const lang of ['en', 'es', 'fr', 'de', 'pt', 'it']) { + const src = read(`frontend/js/i18n/${lang}.js`); + assert.match(src, /'device\.info\.local_ip6':/, `${lang} is missing device.info.local_ip6`); + } +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 2b602c9..c56ea82 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -1170,8 +1170,8 @@ module.exports = function setupDeviceSocket(io) { if (telemetry && deviceExists(device_id)) { db.prepare(` INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb, - ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, temperature_c) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, local_ip6, temperature_c) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( device_id, telemetry.battery_level ?? null, @@ -1187,6 +1187,9 @@ module.exports = function setupDeviceSocket(io) { // Device-supplied text headed for a column the dashboard renders: trim and cap it. // 45 chars is the longest legitimate value (a full IPv6 address). typeof telemetry.local_ip === 'string' ? telemetry.local_ip.trim().slice(0, 45) || null : null, + // Same treatment for the v6 address. 45 is still the cap: it is the longest legitimate + // IPv6 text form (an IPv4-mapped one, `::ffff:255.255.255.255`). + typeof telemetry.local_ip6 === 'string' ? telemetry.local_ip6.trim().slice(0, 45) || null : null, // Only a finite number is a reading. A panel with no sensor sends nothing, and NaN or // Infinity from a flaky one must land as "no reading" rather than poisoning the column. typeof telemetry.temperature_c === 'number' && Number.isFinite(telemetry.temperature_c) diff --git a/tizen/css/style.css b/tizen/css/style.css index 85c12e3..5c0cc36 100644 --- a/tizen/css/style.css +++ b/tizen/css/style.css @@ -1,5 +1,21 @@ * { margin: 0; padding: 0; box-sizing: border-box; } +/* + * ONE KNOB for every pre-playback screen, matching server/player/index.html. + * + * A pairing code is read from across a room, so what must stay constant is ANGULAR size, not + * pixel size — and a CSS pixel covers a sixteenth of the screen area on an 8K panel that it does + * on 1080p. Sizes below are rem against this root: 1rem = 10px at a 1080-tall viewport, and it + * scales from there. A Tizen web app is handed a 1920x1080 logical viewport on most panels, where + * this is arithmetically identical to the pixel values it replaces — the point is the panels where + * it is not. + * + * vmin rather than vw: portrait-mounted panels are common and vw would halve everything on one. + * Only the setup/pairing/toast chrome uses rem; the stage and PiP layers are laid out in %/vw/vh + * and are untouched. + */ +html { font-size: clamp(7px, 0.926vmin, 56px); } + html, body { width: 100%; height: 100%; background: #000; color: #f1f5f9; @@ -19,39 +35,39 @@ html, body { .card { background: #111827; border: 1px solid #1f2937; - border-radius: 18px; - padding: 48px 64px; + border-radius: 1.8rem; + padding: 4.8rem 6.4rem; text-align: center; - max-width: 760px; + max-width: 76rem; } -.card h1 { color: #3b82f6; font-size: 44px; margin-bottom: 6px; } -.sub { color: #94a3b8; font-size: 22px; margin-bottom: 36px; } -.card label { display: block; text-align: left; color: #94a3b8; font-size: 18px; margin-bottom: 8px; } +.card h1 { color: #3b82f6; font-size: 4.4rem; margin-bottom: 0.6rem; } +.sub { color: #94a3b8; font-size: 2.2rem; margin-bottom: 3.6rem; } +.card label { display: block; text-align: left; color: #94a3b8; font-size: 1.8rem; margin-bottom: 0.8rem; } #serverUrl { - width: 100%; font-size: 26px; padding: 16px 20px; - border-radius: 10px; border: 2px solid #334155; - background: #0b1220; color: #f1f5f9; margin-bottom: 24px; + width: 100%; font-size: 2.6rem; padding: 1.6rem 2rem; + border-radius: 1rem; border: 0.2rem solid #334155; + background: #0b1220; color: #f1f5f9; margin-bottom: 2.4rem; } #serverUrl:focus { outline: none; border-color: #3b82f6; } button { - font-size: 24px; font-weight: bold; color: #fff; - background: #3b82f6; border: none; border-radius: 10px; - padding: 16px 40px; cursor: pointer; + font-size: 2.4rem; font-weight: bold; color: #fff; + background: #3b82f6; border: none; border-radius: 1rem; + padding: 1.6rem 4rem; cursor: pointer; } -button:focus { outline: 3px solid #93c5fd; } -button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-top: 24px; padding: 8px; } +button:focus { outline: 0.3rem solid #93c5fd; } +button.ghost { background: transparent; color: #64748b; font-size: 1.8rem; margin-top: 2.4rem; padding: 0.8rem; } -.status { color: #64748b; font-size: 18px; margin-top: 20px; min-height: 24px; } +.status { color: #64748b; font-size: 1.8rem; margin-top: 2rem; min-height: 2.4rem; } .status.error { color: #ef4444; } /* Pairing code */ .code { - font-size: 96px; font-weight: bold; letter-spacing: 16px; - color: #22c55e; margin: 24px 0; font-family: monospace; + font-size: 9.6rem; font-weight: bold; letter-spacing: 1.6rem; + color: #22c55e; margin: 2.4rem 0; font-family: monospace; } -.hint { color: #94a3b8; font-size: 20px; line-height: 1.5; } +.hint { color: #94a3b8; font-size: 2rem; line-height: 1.5; } /* Playback stage */ .stage { background: #000; } @@ -82,8 +98,8 @@ button.ghost { background: transparent; color: #64748b; font-size: 18px; margin- /* Toast */ .toast { - position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); + position: absolute; bottom: 2.4rem; left: 50%; transform: translateX(-50%); background: rgba(17,24,39,0.92); color: #f1f5f9; - padding: 12px 24px; border-radius: 10px; font-size: 18px; - border: 1px solid #334155; + padding: 1.2rem 2.4rem; border-radius: 1rem; font-size: 1.8rem; + border: 0.1rem solid #334155; }