From 1f2e9230052aa3fc18e6ab69b81080f999a56750 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 19 Jun 2026 15:32:11 -0500 Subject: [PATCH] fix(#134): quiet false "reconnect" log + report HDMI output and UI render resolution (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two device-REPORTING fixes from the #134 investigation (the PiP rendering itself was #135). 1) "Device reconnects every ~45s" was a logging artifact, not instability. The player re-emits a full device:register on the SAME socket every ~45-60s (requestPlaylistRefresh) to pull a fresh playlist; the server logged "Device reconnected" for every register of a known device. The attached 4-day log showed 1415 "reconnected" vs 30 real socket connects and 0 heartbeat timeouts — the socket never dropped, so #134's "PiP lost between reconnects" was a misdiagnosis. Fix: only log a genuine reconnect (new socket); a same-socket re-register is a refresh (currentDeviceId === device_id) and stays quiet. The playlist still refreshes. 2) Device reported 720p while the monitor showed a 1080 signal. DeviceInfo reported getRealMetrics() — the UI RENDER SURFACE — but TV boxes render the UI at 720p and upscale to a 1080p HDMI signal. Now report BOTH: screen_width/height = the output mode (Display.Mode.physicalWidth/Height), render_width/height = the render surface (getRealMetrics). Two new nullable devices columns, stored on pairing INSERT + reconnect UPDATE, exposed via the device API, shown on the dashboard as "1920x1080 (UI 1280x720)" when they differ. Backward compatible (required + verified on emulator): a device that omits render_* — or sends no device_info at all — still registers, with render_* = null, on both the INSERT and UPDATE paths. New columns nullable; stores use `?? null` / `|| null`. All 167 server tests pass. Co-authored-by: Claude Opus 4.8 (1M context) --- .../player/telemetry/DeviceInfo.kt | 41 ++++++++-- docs/134-device-reporting.md | 80 +++++++++++++++++++ frontend/js/views/device-detail.js | 9 ++- server/db/database.js | 6 ++ server/routes/devices.js | 2 +- server/ws/deviceSocket.js | 24 ++++-- 6 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 docs/134-device-reporting.md 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 4f6ab97..7b93e22 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 @@ -37,12 +37,18 @@ class DeviceInfo(private val context: Context) { } fun getDeviceInfo(): JSONObject { - val display = getDisplayMetrics() + // Report BOTH: screen_* = the HDMI/panel OUTPUT resolution (Display.Mode), render_* = + // the UI render surface (getRealMetrics). On TV boxes that render at 720p and upscale + // to a 1080p signal these differ — surfacing both explains the discrepancy (#134). + val (outW, outH) = getOutputResolution() + val (renW, renH) = renderSurfaceSize() return JSONObject().apply { put("android_version", Build.VERSION.RELEASE) put("app_version", getAppVersion()) - put("screen_width", display.widthPixels) - put("screen_height", display.heightPixels) + put("screen_width", outW) + put("screen_height", outH) + put("render_width", renW) + put("render_height", renH) } } @@ -126,12 +132,37 @@ class DeviceInfo(private val context: Context) { return SystemClock.elapsedRealtime() / 1000 } - private fun getDisplayMetrics(): DisplayMetrics { + /** + * The display's actual OUTPUT resolution — the HDMI / panel signal — taken from the + * active [android.view.Display.Mode]. This is deliberately NOT getRealMetrics(): many + * Android TV boxes/sticks (and TV-OS builds like YaOS) render the UI into a lower + * surface — commonly 1280x720 — and let the hardware scaler upscale it to a 1920x1080 + * (or 4K) HDMI signal. getRealMetrics() reports that 720p RENDER SURFACE, so a panel + * receiving a real 1080p signal was being reported as 720p. Display.Mode.physicalWidth/ + * Height reports the true output mode (orientation-independent — the panel doesn't rotate + * when we software-rotate the stage). Falls back to the render surface if no mode is + * available. (#134 follow-up: "device reports 720p while the monitor shows a 1080 signal".) + */ + private fun getOutputResolution(): Pair { + return try { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + @Suppress("DEPRECATION") + val mode = wm.defaultDisplay?.mode + val pw = mode?.physicalWidth ?: 0 + val ph = mode?.physicalHeight ?: 0 + if (pw > 0 && ph > 0) pw to ph else renderSurfaceSize() + } catch (e: Throwable) { + renderSurfaceSize() + } + } + + /** Fallback: the UI render-surface size (getRealMetrics). May be < the output mode. */ + private fun renderSurfaceSize(): Pair { val dm = DisplayMetrics() val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @Suppress("DEPRECATION") wm.defaultDisplay.getRealMetrics(dm) - return dm + return dm.widthPixels to dm.heightPixels } private fun getAppVersion(): String { diff --git a/docs/134-device-reporting.md b/docs/134-device-reporting.md new file mode 100644 index 0000000..19b09cb --- /dev/null +++ b/docs/134-device-reporting.md @@ -0,0 +1,80 @@ +# #134 follow-ups — device reporting fixes + +Two issues surfaced while investigating #134 (the PiP bug report). Neither is the +PiP rendering itself (that was #135) — both are device *reporting* problems. + +## 1. "Device reconnects every ~45s" — a logging artifact, not instability + +**Symptom (#134):** the server log shows the device reconnecting every ~45s, read +as an unstable WebSocket that could drop PiP commands. + +**Reality:** the connection is stable. The player calls `requestPlaylistRefresh()` +every ~45–60s, which **re-emits a full `device:register` on the *same* socket** to +pull a fresh playlist. The server's register handler logged `Device reconnected` +for *every* register of a known device, so a healthy device that re-registers +~2000×/day looked like it was flapping. + +Evidence from the attached 4-day log: + +| Signal | Count | +|--------|-------| +| `Device reconnected` | **1415** | +| `Device socket connected` (real) | 30 | +| `Device disconnected` (real) | 21 | +| `marked offline (heartbeat timeout)` | **0** | + +1415 "reconnects" vs 30 real socket connects, and the socket **never** timed out. +So #134's "PiP lost between reconnects / queue TTL-expired" was a misdiagnosis — +the socket doesn't drop; the PiP failure was the rendering bugs fixed in #135. + +**Fix** (`ws/deviceSocket.js`): a re-register on the *same* socket +(`currentDeviceId === device_id`) is a playlist refresh, not a reconnect — only +log `Device reconnected` for a genuinely new socket. The refresh still resends the +playlist; it just no longer spams the log / reads as instability. + +Verified on the emulator: a periodic refresh was processed (device received a new +playlist) while the server's `Device reconnected` count stayed flat; two genuine +reconnects logged exactly twice. + +> Follow-up (not done here): the full re-register every ~45s is heavier than it +> needs to be (re-runs fingerprint/token/eviction + resends the playlist). A +> lightweight `device:request-playlist` event would cut that churn. Left as a +> separate optimization. + +## 2. Reports 720p while the monitor shows a 1080 signal + +**Symptom (#134-adjacent):** a panel receiving a real 1080p HDMI signal was +reported as 720p. + +**Cause:** `DeviceInfo` reported `getRealMetrics()` — the **UI render surface**. +Many Android TV boxes/sticks (YaOS, Fire TV, etc.) render the UI at 1280×720 and +let the hardware scaler upscale to a 1920×1080 (or 4K) HDMI signal. `getRealMetrics` +honestly reports the 720p render surface; the monitor sees the 1080p output mode. +They are two different numbers. + +**Fix:** report **both**, so neither is lost: + +- `screen_width` / `screen_height` = the **HDMI/panel output** mode, from + `Display.getMode().getPhysicalWidth()/getPhysicalHeight()` (orientation-independent; + the panel doesn't rotate when the stage is software-rotated). This is the headline + resolution and now reads 1080 on those boxes. +- `render_width` / `render_height` = the **UI render surface**, from `getRealMetrics()`. + +Wiring: Android `DeviceInfo.getDeviceInfo()` → two new nullable `devices` columns +(`render_width`, `render_height`, migration) → stored in both the pairing INSERT and +the reconnect UPDATE → exposed via the device API (`SELECT d.*`) → the dashboard +device detail shows `1920x1080 (UI 1280x720)` when they differ. + +### Backward compatibility + +Required and verified: a device that doesn't report the new fields must still be +accepted. + +- New columns are **nullable**; the store uses `device_info.render_width ?? null` + (reconnect) and `device_info?.render_width || null` (pairing); `device_info` + itself remains optional. +- Verified on the emulator: an old-style register with `screen_*` but no `render_*`, + and a register with **no `device_info` at all**, both succeed with `render_*` = + null — on both the INSERT (pairing) and UPDATE (reconnect) paths. +- The dashboard only appends `(UI …)` when `render_*` is present and differs, so + legacy devices render as before. diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 94570d8..70e2eb9 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -305,7 +305,14 @@ async function loadDevice(deviceId, activeTab = null) { ` : ''}
${t('device.info.screen_resolution')}
-
${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}
+
${device.screen_width && device.screen_height + ? device.screen_width + 'x' + device.screen_height + + // #134: show the UI render surface alongside the HDMI output when they differ + // (TV boxes that render at 720p and upscale to a 1080p signal). + (device.render_width && device.render_height && + (device.render_width !== device.screen_width || device.render_height !== device.screen_height) + ? ` (UI ${device.render_width}x${device.render_height})` : '') + : '--'}
${t('device.clock.label')}
diff --git a/server/db/database.js b/server/db/database.js index efb559d..e7d9f07 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -210,6 +210,12 @@ const migrations = [ // #106: cosmetic per-workspace display ordering for the Displays view (drag-to- // reorder). Default 0 -> existing devices fall back to the created_at tiebreak. "ALTER TABLE devices ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", + // #134: distinguish the HDMI/panel OUTPUT resolution (screen_width/height, from + // Display.Mode) from the UI RENDER SURFACE (render_width/height, from getRealMetrics). + // TV boxes/sticks often render the UI at 1280x720 and scale it up to a 1080p/4K HDMI + // signal, so the two differ — surfacing both explains "reports 720 but monitor sees 1080". + "ALTER TABLE devices ADD COLUMN render_width INTEGER", + "ALTER TABLE devices ADD COLUMN render_height INTEGER", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/routes/devices.js b/server/routes/devices.js index 1cabe08..1b3fc1c 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -74,7 +74,7 @@ router.get('/unassigned', (req, res) => { } const devices = db.prepare(` SELECT id, pairing_code, status, ip_address, android_version, app_version, - screen_width, screen_height, created_at, last_heartbeat + screen_width, screen_height, render_width, render_height, created_at, last_heartbeat FROM devices WHERE user_id IS NULL ORDER BY created_at DESC `).all(); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index a9ae681..da9dc35 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -338,6 +338,14 @@ module.exports = function setupDeviceSocket(io) { // Reconnecting known device — require valid token const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id); if (device) { + // A re-register on the SAME socket is a playlist REFRESH, not a reconnect: the + // player re-emits device:register every ~45-60s (requestPlaylistRefresh) to pull a + // fresh playlist, and the socket never dropped. currentDeviceId is still null on a + // genuinely new socket and already === device_id on a same-socket refresh. Tracking + // this stops a healthy device (~2000 re-registers/day) from spamming "Device + // reconnected" and reading as connection instability (#134 — there were 1415 + // "reconnected" logs against only ~30 real socket connects and 0 heartbeat timeouts). + const isPlaylistRefresh = currentDeviceId === device_id; // Validate device token (skip for legacy devices that don't have a token yet) if (device.device_token && !validateDeviceToken(device_id, device_token)) { console.warn(`Invalid device token for ${device_id} from ${getClientIp(socket)} — received_len=${(device_token || '').length}, stored_len=${device.device_token.length}, received_prefix=${(device_token || '').substring(0, 8)}, stored_prefix=${device.device_token.substring(0, 8)}`); @@ -364,8 +372,8 @@ module.exports = function setupDeviceSocket(io) { } if (device_info) { - db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?') - .run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_id); + db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ? WHERE id = ?') + .run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_info.render_width ?? null, device_info.render_height ?? null, device_id); } heartbeat.registerConnection(device_id, socket.id); @@ -418,7 +426,9 @@ module.exports = function setupDeviceSocket(io) { } emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online' }); - console.log(`Device reconnected: ${device_id}`); + // Only log a genuine reconnect (new socket). Same-socket periodic refreshes stay + // quiet so the log reflects real connection events, not the 45s refresh cadence. + if (!isPlaylistRefresh) console.log(`Device reconnected: ${device_id}`); return; } @@ -436,14 +446,16 @@ module.exports = function setupDeviceSocket(io) { authenticated = true; db.prepare(` - INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat) - VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now')) + INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, render_width, render_height, last_heartbeat) + VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) `).run( id, pairing_code, newToken, getClientIp(socket), device_info?.android_version || null, device_info?.app_version || null, device_info?.screen_width || null, - device_info?.screen_height || null + device_info?.screen_height || null, + device_info?.render_width || null, + device_info?.render_height || null ); heartbeat.registerConnection(id, socket.id);