android: two reasons the Glyph stayed dark

Both silent, and each alone was enough to leave the matrix blank while
everything else in the app worked.

**1. `setAppMatrixFrame` does nothing on this firmware.** The developer kit is
explicit that app-driven control must use the `App` variants, so that is what
the first cut used. On Phone (3) build 2608141733 the call is accepted without
an exception and then dropped: it forwards to `IGlyphService.setAppMatrixColors`
and the system service never reaches the hardware. Measured over 20 seconds of
1 Hz pushes: `setAppMatrixFrame` produced 0 writes to the matrix light,
`setMatrixFrame` produced 232. Both paths now use the plain variant. The kit's
warning that it can fight a selected Glyph Toy is real, but a rear display that
never lights is the worse failure.

**2. `getDeviceMatrixLength()` returns the side, not the dot count.** The name
invites the opposite reading, and the SDK settles it: `DEVICE_23112_MATRIX_LENGTH`
is 25 and `DEVICE_23112_SIZE` is that squared. Taking a square root of 25 gave a
5x5 frame, so the renderer drew a few stray dots in one corner and `GlyphFrame.set`
silently discarded everything outside the grid — 7 lit dots instead of 78.

Also: `clear()` blanks by drawing an empty frame rather than calling
`closeAppMatrix()`, which was closing a channel we no longer open and so left
the last frame lit.

Verified on the device: 78 of 625 dots for a running interval, 234 hardware
writes in 20s, the frame rendering as minutes over seconds inside a draining
ring. 225 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-19 16:16:30 +03:00
1 parent a7148b69c6
commit 535830bacc
1 file changed
+44 -22
@@ -13,10 +13,11 @@
* last one is remembered and re-sent on connect — otherwise starting an interval * last one is remembered and re-sent on connect — otherwise starting an interval
* while the service is still binding leaves the matrix dark until the next tick. * while the service is still binding leaves the matrix dark until the next tick.
* *
* * **`setAppMatrixFrame`, not `setMatrixFrame`.** The developer kit is explicit: * * **`setAppMatrixFrame` does not work on this firmware.** The developer kit says
* app-driven control must use the `App` variants, because `setMatrixFrame` fights * app-driven control must use the `App` variants. On Phone (3) build 2608141733 it
* with whatever Glyph Toy the user has selected. `KadenceGlyphToy` is the one place * is accepted without an exception and then dropped — 0 hardware writes against 232
* that legitimately calls the plain variant, because there it *is* the toy. * for `setMatrixFrame` over the same 20 seconds. So both paths use the plain
* variant; see the note in `push`.
* *
* * **Every call throws `GlyphException`.** Including after the service dies under * * **Every call throws `GlyphException`.** Including after the service dies under
* you, which happens on a system update. A rear-display failure must never take * you, which happens on a system update. A rear-display failure must never take
@@ -48,8 +49,8 @@ internal class MatrixGlyphDisplay(context: Context) : GlyphDisplay {
@Volatile @Volatile
private var lastFrame: IntArray? = null private var lastFrame: IntArray? = null
@Volatile /** One line per connection, not one per second. */
private var lastAsToy: Boolean = false private var loggedFirstPush = false
override val available: Boolean get() = connected override val available: Boolean get() = connected
@@ -61,7 +62,7 @@ internal class MatrixGlyphDisplay(context: Context) : GlyphDisplay {
val registered = runCatching { gm.register(target) }.getOrDefault(false) val registered = runCatching { gm.register(target) }.getOrDefault(false)
connected = registered connected = registered
Log.i(TAG, "glyph matrix connected (target=$target registered=$registered)") Log.i(TAG, "glyph matrix connected (target=$target registered=$registered)")
if (registered) lastFrame?.let { push(it, lastAsToy) } if (registered) lastFrame?.let { push(it, asToy = false) }
} }
override fun onServiceDisconnected(component: ComponentName?) { override fun onServiceDisconnected(component: ComponentName?) {
@@ -107,18 +108,31 @@ internal class MatrixGlyphDisplay(context: Context) : GlyphDisplay {
override fun show(dots: IntArray, asToy: Boolean) { override fun show(dots: IntArray, asToy: Boolean) {
lastFrame = dots lastFrame = dots
lastAsToy = asToy
if (!connected) return if (!connected) return
push(dots, asToy) push(dots, asToy)
} }
@Suppress("UNUSED_PARAMETER") // see the note below: both paths are the same call
private fun push(dots: IntArray, asToy: Boolean) { private fun push(dots: IntArray, asToy: Boolean) {
val gm = manager ?: return val gm = manager ?: return
try { try {
// The developer kit's rule: the App variant for app-driven control, the // `setMatrixFrame` for both paths, which contradicts the developer kit's
// plain one only when we are the selected toy. Using setMatrixFrame for // "always use setAppMatrixFrame for app control" — deliberately, because
// app control visibly fights whatever toy the user has chosen. // on this firmware (Phone (3), build 2608141733) the App variant does
if (asToy) gm.setMatrixFrame(dots) else gm.setAppMatrixFrame(dots) // nothing at all. It is accepted without an exception and then silently
// dropped: `setAppMatrixFrame` forwards to IGlyphService.setAppMatrixColors
// and the system service never reaches the hardware. Measured over 20s of
// 1 Hz pushes: setAppMatrixFrame produced 0 writes to the matrix light,
// setMatrixFrame produced 232.
//
// The kit's warning is still real — the plain variant can fight a selected
// Glyph Toy — but a rear display that never lights is the worse failure.
// Revisit if a later firmware makes the App variant work.
gm.setMatrixFrame(dots)
if (!loggedFirstPush) {
loggedFirstPush = true
Log.i(TAG, "glyph: first frame away, ${dots.count { it > 0 }} of ${dots.size} lit")
}
} catch (e: Throwable) { } catch (e: Throwable) {
// Includes GlyphException. The rear display is a nicety; the interval is not. // Includes GlyphException. The rear display is a nicety; the interval is not.
Log.d(TAG, "glyph frame rejected: ${e.message}") Log.d(TAG, "glyph frame rejected: ${e.message}")
@@ -126,14 +140,15 @@ internal class MatrixGlyphDisplay(context: Context) : GlyphDisplay {
} }
override fun clear() { override fun clear() {
val wasToy = lastAsToy
lastFrame = null lastFrame = null
loggedFirstPush = false
val gm = manager ?: return val gm = manager ?: return
try { try {
// A toy blanks by drawing nothing; closeAppMatrix is for app control and // Blank by drawing nothing rather than `closeAppMatrix()`: we are not using
// would hand the matrix back to the shell mid-toy. // the app-matrix channel at all (see `push`), so closing it would be a
if (wasToy) gm.setMatrixFrame(IntArray(glyphMatrixSize() * glyphMatrixSize())) // no-op on a channel we never opened, leaving the last frame lit.
else gm.closeAppMatrix() val side = glyphMatrixSize()
gm.setMatrixFrame(IntArray(side * side))
} catch (e: Throwable) { } catch (e: Throwable) {
Log.d(TAG, "glyph clear failed: ${e.message}") Log.d(TAG, "glyph clear failed: ${e.message}")
} }
@@ -150,13 +165,20 @@ internal class MatrixGlyphDisplay(context: Context) : GlyphDisplay {
internal fun newGlyphDisplay(context: Context): GlyphDisplay = MatrixGlyphDisplay(context) internal fun newGlyphDisplay(context: Context): GlyphDisplay = MatrixGlyphDisplay(context)
/**
* The matrix's side, in dots.
*
* `getDeviceMatrixLength()` returns the **side**, not the dot count — the SDK's own
* `Glyph.DEVICE_23112_MATRIX_LENGTH` is 25 and `DEVICE_23112_SIZE` is that squared.
* The name invites exactly the wrong reading: treating 25 as a total and taking its
* square root yields a 5x5 frame, on which the renderer draws a handful of stray dots
* in the corner and everything else falls outside the grid and is silently discarded.
*
* Phone (4a) Pro reports 13 here, so this genuinely has to be asked rather than
* assumed; 0 means a device with no matrix.
*/
internal fun glyphMatrixSize(): Int = internal fun glyphMatrixSize(): Int =
runCatching { Common.getDeviceMatrixLength() } runCatching { Common.getDeviceMatrixLength() }
.getOrNull() .getOrNull()
?.takeIf { it > 0 } ?.takeIf { it > 0 }
// The SDK reports the *total* dot count; the renderer works in a square.
?.let { total ->
val side = Math.round(Math.sqrt(total.toDouble())).toInt()
if (side * side == total) side else GlyphFrame.DEFAULT_SIZE
}
?: GlyphFrame.DEFAULT_SIZE ?: GlyphFrame.DEFAULT_SIZE