protocol: read the watch's refusals instead of dropping them - #399
Open
zunda-pixel wants to merge 3 commits into
Open
zunda-pixel wants to merge 3 commits into
zunda-pixel wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
💡 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
force-pushed
the
meta-endpoint
branch
from
September 3, 2026 10:07
d1c8300 to
4885716
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.cin PebbleOS):0xd00xdc0xddNothing decodes it:
ProtocolEndpoint.RECOVERY(0u)is declared and never referenced anywhere in the tree.PacketRegistryhas no decoder for it, soget()throwsPacketDecodeException("No packet class registered for endpoint RECOVERY").PebbleProtocolRunnerswallows that exception and drops the packet — and itsLogger.wis commented out, so nothing is logged either:So a refusal is indistinguishable from silence, and a caller awaiting the real answer waits for a reply that is never coming.
SystemService.sendPinghas no deadline of its own — unlikegetFirmwareUpdateStatustwo dozen lines above it, which wraps its await inwithTimeoutOrNull— 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
MetaMessagedecodes the endpoint. The endpoint id isSOptional, because0xd0does not carry one.SystemServicefails a pending ping when the watch says it will not answer one — the same shape asWatchFactoryDataErrorfailing a pending watch-model request a few lines above.RECOVERY(0u)becomesMETA(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
requestWatchVersionandgetWatchModelawait 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.Logger.winPebbleProtocolRunneris left alone — restoring it looks like a good idea and is a separate concern.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
MetaTestcovers the two shapes the meta endpoint sends: the refusal that names an endpoint, and the one that cannot.Full suite on this branch: 206 tests, no failures. The third case is the one worth having — it is why
rejectedEndpointis anSOptional, 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:compileKotlinJvmand:libpebble3:jvmTestwork oncelocal.propertiesnames an Android SDK and a JDK 17 is on the toolchain path. (compileKotlinMetadatareports 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:
PacketRegistry.register(ProtocolEndpoint.APP_LOGS) { AppLogReceivedMessage() }PebblePacket.deserializeconsumes the 4-byte frame into a throwaway mapper before callingpacket.m.fromBytes(buf), so the fields start after the headerSOptional.fromBytesmarks itself absent when fewer thanvalue.sizebytes remain, which is what makes the0xd0case workStructMapper(), as inPutBytes.kt:49UBytehas notoString(radix), so hex goes throughtoInt(), as inProtocolEndpoint.kt:47SystemService's ping path is not covered: driving it needs a connection scope and apongCallbackin 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.