Skip to content

protocol: read the watch's refusals instead of dropping them - #399

Open
zunda-pixel wants to merge 3 commits into
coredevices:masterfrom
zunda-pixel:meta-endpoint
Open

zunda-pixel wants to merge 3 commits into
coredevices:masterfrom
zunda-pixel:meta-endpoint

Conversation

@zunda-pixel

@zunda-pixel zunda-pixel commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What happens today

Endpoint 0 is the meta endpoint. The watch answers there when it will not answer on the endpoint that was addressed — an error code plus the id of the request it concerns (src/fw/services/comm_session/meta_endpoint.c in PebbleOS):

code meaning
0xd0 corrupted message (no endpoint id follows)
0xdc endpoint not handled
0xdd endpoint not allowed

Nothing decodes it:

  1. ProtocolEndpoint.RECOVERY(0u) is declared and never referenced anywhere in the tree.
  2. PacketRegistry has no decoder for it, so get() throws PacketDecodeException("No packet class registered for endpoint RECOVERY").
  3. PebbleProtocolRunner swallows that exception and drops the packet — and its Logger.w is commented out, so nothing is logged either:
val packet = try { PebblePacket.deserialize(packetBytes) }
catch (e: CancellationException) { throw e }
catch (e: Exception) {
//  Logger.w("error deserializing packet: $packetBytes", e)
    null
}

So a refusal is indistinguishable from silence, and a caller awaiting the real answer waits for a reply that is never coming. SystemService.sendPing has no deadline of its own — unlike getFirmwareUpdateStatus two dozen lines above it, which wraps its await in withTimeoutOrNull — so it never returns.

Where this bites

A watch in its recovery firmware refuses nearly every endpoint. Ping it and it answers endpoint 0 with DC 07 D1 — "endpoint 2001 is not handled here" — which is both a refusal and proof the watch is alive.

I hit this from a third-party iOS companion app I maintain: reading the refusal as "no answer" made me tear the link down, which stopped a firmware install on a recovery-firmware watch every time. Reading it as "alive, but not on that endpoint" fixed it. Observed on a Pebble Time 2 (obelix_pvt) in PRF v4.9.142.

The change

  • MetaMessage decodes the endpoint. The endpoint id is SOptional, because 0xd0 does not carry one.
  • SystemService fails a pending ping when the watch says it will not answer one — the same shape as WatchFactoryDataError failing a pending watch-model request a few lines above.
  • RECOVERY(0u) becomes META(0u). The old name has nothing to do with the recovery firmware and has no references; happy to drop that hunk if you would rather keep it.

Deliberately not in scope

  • requestWatchVersion and getWatchModel await without deadlines too, and could be failed from the same handler. Recovery firmware does answer endpoint 16, so they are less exposed; say the word and I will wire them.
  • The commented-out Logger.w in PebbleProtocolRunner is left alone — restoring it looks like a good idea and is a separate concern.
  • No timeout was added to sendPing. That changes what callers see on a genuinely silent watch, which is a semantics decision for you rather than a bug fix. A refusal now unblocks it; silence still does not.

Tests

MetaTest covers the two shapes the meta endpoint sends: the refusal that names an endpoint, and the one that cannot.

./gradlew :libpebble3:jvmTest --tests 'io.rebble.libpebblecommon.packets.MetaTest'
  anEndpointTheWatchDoesNotHandleIsNamed          PASS
  anEndpointTheWatchWillNotAllowIsNamedTheSameWay PASS
  aCorruptedMessageNamesNoEndpoint                PASS

Full suite on this branch: 206 tests, no failures. The third case is the one worth having — it is why rejectedEndpoint is an SOptional, and it fails if absent decodes as endpoint 0 instead of as absent.

Correcting my own earlier note: an earlier version of this description said there was no Gradle wrapper in the tree and that I could not compile Kotlin here. Both were wrong, and I had not checked either. ./gradlew :libpebble3:compileKotlinJvm and :libpebble3:jvmTest work once local.properties names an Android SDK and a JDK 17 is on the toolchain path. (compileKotlinMetadata reports SKIPPED and verifies nothing, which is what misled me the first time.)

The checks I had made by reading still hold, and are what the tests now confirm:

  • the universal-decoder shape matches PacketRegistry.register(ProtocolEndpoint.APP_LOGS) { AppLogReceivedMessage() }
  • PebblePacket.deserialize consumes the 4-byte frame into a throwaway mapper before calling packet.m.fromBytes(buf), so the fields start after the header
  • SOptional.fromBytes marks itself absent when fewer than value.size bytes remain, which is what makes the 0xd0 case work
  • the inner element takes a throwaway StructMapper(), as in PutBytes.kt:49
  • UByte has no toString(radix), so hex goes through toInt(), as in ProtocolEndpoint.kt:47

SystemService's ping path is not covered: driving it needs a connection scope and a pongCallback in flight, which is a larger fixture than the decode. Happy to add it if you would rather have it.

AI use

Written with Claude Code (Claude Opus 5), disclosed per CONTRIBUTING. I have read the change and the firmware behaviour it describes, and can explain either.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1c8300454

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Endpoint 0 is the meta endpoint: the watch answers there when it will not
answer on the endpoint that was addressed, with an error code and the id
of the request it is about. Nothing decoded it, so PacketRegistry threw,
the inbound loop swallowed the exception (its warning is commented out),
and the refusal reached nobody.

Whoever was waiting for the real answer therefore waited for a reply that
was never coming. sendPing has no deadline of its own — unlike
getFirmwareUpdateStatus, which does — so a ping to a watch in its recovery
firmware never returned. That firmware refuses nearly every endpoint, and
answers endpoint 0 with `DC 07 D1`: "endpoint 2001 is not handled here".

MetaMessage now decodes it, and SystemService fails a pending ping when
the watch says it will not answer one, the way WatchFactoryDataError
already fails a pending watch-model request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zunda-pixel and others added 2 commits September 3, 2026 22:38
The refusal that names an endpoint and the one that cannot. The second is
the reason rejectedEndpoint is an SOptional: the firmware sends 0xd0 with no
endpoint after it, and absent has to read as absent rather than as endpoint 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant