fix(#134): quiet false "reconnect" log + report HDMI output and UI render resolution

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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-19 15:29:08 -05:00
parent 7660d7433e
commit 7f9d403cb0
6 changed files with 149 additions and 13 deletions

View file

@ -37,12 +37,18 @@ class DeviceInfo(private val context: Context) {
} }
fun getDeviceInfo(): JSONObject { 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 { return JSONObject().apply {
put("android_version", Build.VERSION.RELEASE) put("android_version", Build.VERSION.RELEASE)
put("app_version", getAppVersion()) put("app_version", getAppVersion())
put("screen_width", display.widthPixels) put("screen_width", outW)
put("screen_height", display.heightPixels) 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 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<Int, Int> {
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<Int, Int> {
val dm = DisplayMetrics() val dm = DisplayMetrics()
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(dm) wm.defaultDisplay.getRealMetrics(dm)
return dm return dm.widthPixels to dm.heightPixels
} }
private fun getAppVersion(): String { private fun getAppVersion(): String {

View file

@ -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 ~4560s, 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.

View file

@ -305,7 +305,14 @@ async function loadDevice(deviceId, activeTab = null) {
` : ''} ` : ''}
<div class="info-card"> <div class="info-card">
<div class="info-card-label">${t('device.info.screen_resolution')}</div> <div class="info-card-label">${t('device.info.screen_resolution')}</div>
<div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div> <div class="info-card-value small">${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})` : '')
: '--'}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">${t('device.clock.label')}</div> <div class="info-card-label">${t('device.clock.label')}</div>

View file

@ -210,6 +210,12 @@ const migrations = [
// #106: cosmetic per-workspace display ordering for the Displays view (drag-to- // #106: cosmetic per-workspace display ordering for the Displays view (drag-to-
// reorder). Default 0 -> existing devices fall back to the created_at tiebreak. // reorder). Default 0 -> existing devices fall back to the created_at tiebreak.
"ALTER TABLE devices ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", "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" // Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign. // error means the column is already present (expected on a migrated DB) - benign.

View file

@ -74,7 +74,7 @@ router.get('/unassigned', (req, res) => {
} }
const devices = db.prepare(` const devices = db.prepare(`
SELECT id, pairing_code, status, ip_address, android_version, app_version, 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 FROM devices WHERE user_id IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
`).all(); `).all();

View file

@ -338,6 +338,14 @@ module.exports = function setupDeviceSocket(io) {
// Reconnecting known device — require valid token // Reconnecting known device — require valid token
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
if (device) { 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) // Validate device token (skip for legacy devices that don't have a token yet)
if (device.device_token && !validateDeviceToken(device_id, device_token)) { 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)}`); 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) { if (device_info) {
db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE 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_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); 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' }); 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; return;
} }
@ -436,14 +446,16 @@ module.exports = function setupDeviceSocket(io) {
authenticated = true; authenticated = true;
db.prepare(` db.prepare(`
INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat) 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')) VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
`).run( `).run(
id, pairing_code, newToken, getClientIp(socket), id, pairing_code, newToken, getClientIp(socket),
device_info?.android_version || null, device_info?.android_version || null,
device_info?.app_version || null, device_info?.app_version || null,
device_info?.screen_width || 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); heartbeat.registerConnection(id, socket.id);