Architecture Overview
Overview
ZSWatch is built on an event-driven architecture powered by Zephyr's zbus publish/subscribe messaging system. Rather than tight coupling between modules, sensors, BLE services, managers, and applications communicate through named channels. Each module publishes data to a channel and any number of listeners can subscribe, making the system modular and easy to extend.
The main architectural layers are:
- Sensors - read hardware and publish data to zbus channels
- BLE services - receive data from the phone and publish to zbus; subscribe to channels to send data back
- Managers - orchestrate system behavior (app lifecycle, power states, notifications)
- Applications - subscribe to relevant channels and render UI via LVGL
System Block Diagram
Click the diagram (or open the zoomable view) to pan and zoom.
Event System (Zbus)
ZSWatch uses Zephyr's zbus for all inter-module communication. Modules publish typed structs to named channels, and any number of listeners or subscribers receive them asynchronously.
Key rules:
- Listeners run in the publisher's context (often a BLE or sensor thread with limited stack). Always use
k_work_submit()for non-trivial processing. - Channels are typed: each carries a specific struct (e.g.,
struct accel_data_event). - Decoupled: publishers don't know who listens; listeners don't know who publishes.
Channel Reference
| Channel | Data | Direction | Description |
|---|---|---|---|
accel_data_chan | Gesture/accel events | Sensor → Apps | IMU interrupt-driven events (tap, tilt, step) |
activity_state_data_chan | Power state enum | Power Mgr → App Mgr | ACTIVE / INACTIVE / NOT_WORN_STATIONARY |
battery_sample_data_chan | mV, %, temp, charging | Sensor → Apps | Battery status updates |
ble_comm_data_chan | Typed BLE payload | BLE → Apps | Notifications, music info, weather, time, etc. |
environment_data_chan | Temp, humidity, pressure, IAQ | Sensor → Apps | Environmental sensor readings |
light_data_chan | Lux value | Sensor → Apps | Ambient light level |
magnetometer_data_chan | x/y/z components | Sensor → Apps | Magnetometer heading data |
music_control_data_chan | Play/pause/next/prev | App → BLE | Music UI commands sent to phone |
pressure_data_chan | Pressure, temperature | Sensor → Apps | Barometric pressure readings |
periodic_event_100ms_chan | Tick | Timer → Subscribers | 100 ms periodic event |
periodic_event_1s_chan | Tick | Timer → Subscribers | 1-second periodic event |
periodic_event_10s_chan | Tick | Timer → Subscribers | 10-second periodic event |
zsw_notification_mgr_chan | Notification | Notif Mgr → Apps | New notification received |
zsw_notification_mgr_remove_chan | Notification ID | Notif Mgr → Apps | Notification dismissed/removed |
voice_memo_event_chan | Voice memo event | Recording Mgr → Apps | Voice memo recorded/deleted/list updated |
Periodic Events
The system provides three shared periodic timer channels: 100 ms, 1 s, and 10 s. These drive most recurring work: sensor polling, UI refresh, battery sampling, etc.
Periodic channels use a lazy start/stop mechanism: the timer only runs when at least one observer is registered. Apps subscribe when they start and unsubscribe when they stop, so idle apps don't waste power.
// Subscribe to 1-second ticks (e.g., in app start_func)
zsw_periodic_chan_add_obs(&periodic_event_1s_chan, &my_listener);
// Unsubscribe (e.g., in app stop_func)
zsw_periodic_chan_rm_obs(&periodic_event_1s_chan, &my_listener);
Typical usage: a watchface subscribes to periodic_event_1s_chan to update the clock, and to periodic_event_10s_chan for refreshing weather or battery status.
Sensor Data Flow
The sensor data pipeline is straightforward:
- A periodic event (1 s or 10 s) fires
- Sensor drivers read hardware and publish to their zbus channel
- Apps/watchfaces that have subscribed receive the data in their listener callback
- The listener schedules a work item to update the UI on the main thread
Periodic Timer → Sensor Driver → zbus channel → App Listener → k_work → UI Update
Sensor modules (IMU, magnetometer, pressure, environment, light) are abstracted behind simple APIs in app/src/sensors/. The IMU is an exception: it is primarily interrupt-driven (gestures, step events) rather than polled.
BLE Communication
ZSWatch supports two phone platforms through different BLE profiles:
| Platform | Protocol | Services |
|---|---|---|
| Android | GadgetBridge JSON | Notifications, music, weather, time, calls, navigation, custom watchface backgrounds |
| iOS | Apple ANCS + AMS + CTS | Notifications (ANCS), media control (AMS), time (CTS) |
Incoming BLE data is parsed and published to ble_comm_data_chan with a type field. Consumers filter by type:
- Notification Manager - handles notification types, stores up to 10
- Music app - handles music info/state types
- Watchface - handles weather, time types, and custom background management
- File transfers - MCUmgr/SMP protocol for firmware updates, file upload/download, and watchface backgrounds
Outbound commands (e.g., music play/pause) flow in reverse: the app publishes to music_control_data_chan, and the BLE module picks it up and sends it to the phone.
The companion app can now upload custom watchface backgrounds via BLE. See the GadgetBridge Protocol guide for details.
Audio System
Audio Playback
The Speaker Manager (zsw_speaker_manager) provides audio playback through the DA7212 audio codec via I2S. It supports three playback modes:
- Callback mode (
ZSW_SPEAKER_SOURCE_CALLBACK) - Real-time audio generation; a fill callback is invoked to produce interleaved stereo 16-bit PCM samples at 48 kHz - Buffer mode (
ZSW_SPEAKER_SOURCE_BUFFER) - Play a pre-loaded PCM buffer - File mode (
ZSW_SPEAKER_SOURCE_FILE) - Stream audio from a file (not yet implemented)
Key API functions:
zsw_speaker_manager_start()- Start playback with a given configurationzsw_speaker_manager_stop()- Stop playback and shut down hardwarezsw_speaker_manager_is_playing()- Query playback status
The speaker manager handles codec configuration, I2S setup, and runs a dedicated streaming thread to feed audio data to the hardware.
Audio Recording
The Recording Manager (zsw_recording_manager) handles voice memo recording and storage. Audio is captured via the microphone driver, encoded using the Opus codec (app/src/codec/), and stored in the filesystem as .opus files.
The SMP Manager (zsw_smp_manager) provides MCUmgr file system access, allowing the companion app to download recorded voice memos from the watch using standard MCUmgr FS commands.
Key features:
- Opus audio codec for efficient compression
- Configurable microphone gain and sample rate
- File storage in
/lfs1/voice_memos/ - BLE notification to companion app when new memo is available
- Companion app downloads via MCUmgr, transcribes, and classifies content
Power Management
The power manager tracks user activity and drives the display and app lifecycle through three states:
| State | Display | Apps | Description |
|---|---|---|---|
| ACTIVE | On | UI_VISIBLE | User is interacting with the watch |
| INACTIVE | Off | UI_HIDDEN | Idle timeout elapsed; screen off to save power |
| NOT_WORN_STATIONARY | Off | UI_HIDDEN | Watch not being worn; deeper power saving |
State changes are published to activity_state_data_chan. The App Manager reacts by calling each app's ui_unavailable_func / ui_available_func callbacks, so apps can pause UI updates when the screen is off.
Always guard UI updates with a state check:
if (app.current_state == ZSW_APP_STATE_UI_VISIBLE) {
// Safe to update LVGL objects
}
For details on building apps that use this architecture, see the Writing Apps guide.