The Pebble protocol carries messages between the watch and phone apps: the Pebble mobile app (notifications, firmware updates, app installs, settings sync, …) and third party phone apps talking to their watch apps. Every message is addressed to an endpoint, a 16-bit ID naming the service that handles it, such as Put Bytes or BlobDB.
subsys/pp/ implements it. Its public API is in include/pbl/pp/:
Header |
For |
|---|---|
|
Wire definitions: message header, endpoint IDs, capabilities |
|
Finding sessions, their capabilities, session listeners |
|
Queueing outbound messages |
|
Handling inbound messages |
|
Links that carry sessions |
|
Entry points of each transport for the code that drives its link |
Wire protocol describes the messages on the wire, and Transports the links that carry them.
The subsystem has three layers, each knowing only the one below:
flowchart TB
subgraph users["Endpoints, senders, listeners"]
direction LR
pb["Put Bytes"]
blob["BlobDB"]
music["Music"]
more["..."]
end
subgraph core["Sessions (subsys/pp)"]
direction LR
rx["Receive<br/>header parsing,<br/>endpoint lookup"]
tx["Send queue<br/>send buffers, jobs"]
sess["Session registry<br/>lookup, capabilities,<br/>listeners"]
end
subgraph transports["Transports (subsys/pp/transports)"]
direction LR
gatt["GATT<br/>(PPoGATT)"]
console["Console"]
qemu["QEMU"]
end
users --> core
core --> transports
gatt --- ble["Kernel LE client"]
console --- cons["Console frames"]
qemu --- qser["QEMU serial"]
classDef app fill:#fa4a36,stroke:#fa4a36,color:#ffffff
classDef system fill:#fd8a7c,stroke:#fd8a7c,color:#171717
classDef base fill:#525252,stroke:#525252,color:#ffffff
class pb,blob,music,more app
class rx,tx,sess system
class gatt,console,qemu,ble,cons,qser base
Endpoints and senders use sessions and know nothing about transports.
Sessions parse, dispatch and queue messages, and know nothing about the links.
Transports move bytes over a link and open and close sessions. The code that drives each link (the kernel LE client, the console, the QEMU serial protocol) calls its transport through the transport’s header.
A session is a Pebble protocol connection with one peer over one transport. It has a destination:
Destination |
Peer |
Session type |
|---|---|---|
System |
The Pebble mobile app |
|
App |
A third party phone app, bound to the UUID of its watch app |
|
Hybrid |
Both, as the Pebble Android app relays PebbleKit traffic |
|
pbl_pp_session_get_system() returns the system session. When more than one
is open, for example the console next to PPoGATT during development, the one
on the transport with the highest priority wins. pbl_pp_session_get_app()
returns the session serving a watch app: an app session bound to its UUID,
or else a hybrid session or an app session whose app is unknown. The
app_session service applies this to the running app, and gives PebbleKit
JS apps the system session.
Opening a system or hybrid session closes the existing system session through its transport, unless either is on a shared transport like the console. This keeps one Pebble mobile app connection at a time: when the app comes back over another link, or relaunches after a crash, the last session to open wins.
stateDiagram-v2
direction LR
[*] --> Open: pbl_pp_session_open()
Open --> Open: messages, capabilities
Open --> Closed: pbl_pp_session_close()<br/>by its transport
Open --> Closed: a new system session<br/>closes it
Closed --> [*]
When a session opens, it asks the phone for its version and capabilities
(see Version and capabilities). Code checks them with
pbl_pp_session_has_capability() before using a feature the phone may not
support.
Sessions close at any time. A session pointer is a handle that every call validates, so code may keep one around and find out later that it is gone; calls on a closed session fail, or do nothing.
Code that reacts to sessions defines a listener next to its own code. The subsystem collects listeners at link time and calls each for every event:
Event |
When |
|---|---|
|
The session opened |
|
The session is closing; it is still valid during the call |
|
The phone announced the session’s capabilities |
static void prv_session_cb(struct pbl_pp_session *session, enum pbl_pp_session_event event,
void *user_data) {
if (event == PBL_PP_SESSION_CLOSED && pbl_pp_session_is_system(session)) {
system_task_add_callback(prv_handle_disconnect, NULL);
}
}
PBL_PP_SESSION_LISTENER_DEFINE(my_service, prv_session_cb, NULL);
Data logging, app launch, analytics and the cache of the system session’s
capabilities are listeners. Listeners run in the context of the transport,
which may hold its own lock, so they must not block. Apps learn about
sessions through PEBBLE_COMM_SESSION_EVENT, which the subsystem puts for
every session that opens or closes.
Each endpoint is defined next to the code that handles it, and collected at
link time. The ID comes from protocol.h, where all of them are listed:
static void prv_ping_handler(struct pbl_pp_session *session, const uint8_t *data,
size_t length) {
...
}
PBL_PP_ENDPOINT_DEFINE(PBL_PP_ENDPOINT_PING, PBL_PP_ACCESS_ANY, prv_ping_handler);
Macro |
Handling |
|---|---|
|
The default receiver copies the payload to the kernel heap and runs the handler on KernelBG |
|
The same, on KernelMain, for handlers whose events must stay ordered with |
|
A receiver of the endpoint’s own buffers the message, such as Put Bytes into its download buffer or AppMessage into the app’s inbox |
The access level decides who may use the endpoint: PBL_PP_ACCESS_PRIVATE
endpoints only accept messages on system sessions, PBL_PP_ACCESS_ANY ones
on app sessions too. Messages for unknown or disallowed endpoints are
answered on the meta endpoint (see Meta endpoint).
An endpoint exists if the code defining it is built. Endpoints left out of
recovery firmware are guarded with CONFIG_RECOVERY_FW, which keeps PRF to
the few endpoints it needs to update the firmware. Each definition is a
symbol named after its ID, so defining an ID twice fails to link, and the
handler’s type is checked.
sequenceDiagram
participant L as Link
participant T as Transport
participant S as Session
participant R as Receiver
participant H as Handler (KernelBG or KernelMain)
L->>T: bytes
T->>S: pbl_pp_session_receive()
Note over S: collect the 4-byte header,<br/>look the endpoint up,<br/>check its access
alt unknown or disallowed
S-->>S: meta error, skip the payload
else accepted
S->>R: prepare(length)
loop payload bytes, split anywhere
T->>S: pbl_pp_session_receive()
S->>R: write()
end
S->>R: finish()
R->>H: queue the message
H->>H: handler(session, payload, length)
end
The transport may split the bytes anywhere: a header may arrive in pieces and a write may hold the end of one message and the start of the next. Each session has one message in progress at a time. The default receiver drains one queue per task, one message per callback, so a slow handler cannot starve the task watchdog and bursts cannot overflow the task queue.
API |
Use |
|---|---|
|
A complete message, copied |
|
A message built piecemeal |
|
The same, for bulk transfers |
|
A message whose memory the caller owns, queued without a copy |
Send buffers come from two budgets of about 1 KiB each, normal and bulk, so that bulk transfers such as data logging or screenshots cannot starve interactive messages; a shared reserve lets either go a little further. Beginning a buffer blocks until there is room or the timeout expires.
sequenceDiagram
participant C as Caller
participant S as Session
participant M as KernelMain
participant T as Transport
participant L as Link
C->>S: pbl_pp_send_buffer_begin()
Note over S: wait for room in the budget
C->>S: pbl_pp_send_buffer_write()
C->>S: pbl_pp_send_buffer_end()
S->>S: append to the send queue
S->>M: schedule send_next (once)
M->>T: send_next(session)
T->>S: copy or peek queued bytes
T->>L: send
L-->>T: delivered (acknowledged)
T->>S: pbl_pp_session_send_queue_consume()
S->>S: free the buffer, wake waiting senders
When the caller is KernelMain, which runs send_next, waiting would never
end: beginning a buffer then sends the queue right away instead.
Sending may block, so it must not be done while holding a lock the
transports take, such as bt_lock().
The session layer has one lock. It protects the session list and each session’s send queue, flags and capabilities, and is never held while calling a transport, a receiver’s handler or a listener.
Each transport protects its own state with its own lock: bt_lock() for
PPoGATT, a mutex each for the console and QEMU. It may hold that lock while
calling into the session, so the lock order is always transport, then
session.
Since the session layer calls transports without its lock, a transport callback may race with the transport closing the session. Callbacks therefore receive the session rather than the transport’s context: the transport looks the session up among its own, under its own lock, and ignores the call if it no longer owns it.
Generated from PebbleOS 439c45a48. This page is maintained in the pebbleos repository: docs/subsystems/pp/index.md.