Introduction
cgd1-rs is a Rust library and toolkit for the Qingping CGD1 Bluetooth alarm clock. It provides a complete BLE transport layer, command protocol implementation, and three frontends: a command-line tool, a GTK 4 desktop application, and a WebSocket/REST server.
Supported Hardware
The Qingping CGD1 is a Bluetooth 5.0 LCD alarm clock with built-in temperature and humidity sensors. It is manufactured by ClearGrass/Qingping.
| Property | Value |
|---|---|
| Model | CGD1 |
| Connectivity | Bluetooth 5.0 (BLE) |
| Sensors | Temperature, humidity (Sensirion) |
| Power | 2 × AA batteries, > 1 year standby |
| Display | LCD with adjustable backlight |
| Alarm slots | Up to 16 |
| Custom ringtones | 2 slots, ~12 s / 98 KB max |
See Hardware Notes for full specifications.
Features
- BLE transport: Scan, connect, authenticate, and communicate with the CGD1 via Bluetooth Low Energy
- Time synchronization: Sync the device clock to the current system time
- Alarm management: Read, set, and delete up to 16 alarms with day-of-week repeat masks and snooze
- Device settings: Read and write volume, brightness, timezone, time format, temperature unit, language, night mode, and screen duration
- Sensor monitoring: Real-time temperature and humidity via BLE notifications, plus passive advertisement parsing
- Battery monitoring: Battery level from BLE advertising scans, persisted in a cache file for display on connect
- Audio upload: Upload custom ringtones (8-bit PCM, 8 kHz, mono) via the block-based BLE protocol
- Firmware query: Read the device firmware version string
- Reconnection: Automatic reconnection with exponential backoff and full state recovery
Crates
| Crate | Description |
|---|---|
cgd1-rs | Core library: BLE transport, auth, commands, events, device handle |
cgd1-rs-cli | Command-line tool with 14 subcommands and interactive REPL |
cgd1-rs-controller | GTK 4 desktop application with sensor display, alarm editor, and settings panel |
cgd1-rs-ws | WebSocket and REST server for network access to the device |
Architecture Overview
graph TD
Core["cgd1-rs<br/>(Core Library)"]
CLI["cgd1-rs-cli<br/>(CLI Tool)"]
Controller["cgd1-rs-controller<br/>(GTK 4 App)"]
WS["cgd1-rs-ws<br/>(WebSocket Server)"]
Device["CGD1 Device<br/>(BLE)"]
CLI --> Core
Controller --> Core
WS --> Core
Core --> Device
All three frontends build on the same core library, which abstracts the BLE protocol, authentication, and command/response handling. See Architecture for details.
License
MIT. See LICENSE.
Getting Started
Prerequisites
Rust
A working Rust toolchain is required. The project uses Rust Edition 2024.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Minimum Supported Rust Version (MSRV)
Each crate declares its own MSRV in its Cargo.toml:
| Crate | MSRV |
|---|---|
cgd1-rs | 1.88 |
cgd1-rs-cli | 1.89 |
cgd1-rs-ws | 1.88 |
cgd1-rs-controller | 1.92 |
Linux System Dependencies
The project requires BlueZ for BLE access via btleplug. The GTK 4 controller additionally requires GTK 4 development libraries.
Ubuntu/Debian:
# Core + CLI + WebSocket server
sudo apt-get install -y pkg-config libdbus-1-dev
# GTK 4 controller (optional)
sudo apt-get install -y libgtk-4-dev
# mdBook documentation (optional)
cargo install mdbook
cargo install mdbook-mermaid
Fedora:
sudo dnf install -y pkg-config dbus-devel gtk4-devel
Arch Linux:
sudo pacman -S dbus gtk4
macOS
macOS uses CoreBluetooth internally. No extra system packages are needed for the core library or CLI. The GTK 4 controller is not supported on macOS.
brew install gtk4
Building from Source
Clone the repository and build all crates:
git clone https://github.com/smearor/cgd1-rs.git
cd cgd1-rs
cargo build --release
To build only a specific crate:
cargo build --release -p cgd1-rs-cli
cargo build --release -p cgd1-rs-ws
cargo build --release -p cgd1-rs-controller
Installation
From Source
cargo install --path cgd1-rs-cli
cargo install --path cgd1-rs-ws
This installs the cgd1 and cgd1-ws binaries into ~/.cargo/bin/.
From crates.io
Not yet published. Build from source for now.
Quick Start
1. Scan for Devices
cgd1 scan --duration 10
Output:
Scanning for 10s...
Found 1 device(s):
MAC: AA:BB:CC:DD:EE:FF
Temperature: 23.4 C
Humidity: 45.6 %
Battery: 87 %
2. Synchronize Time
The first command after scanning should be time synchronization. This also confirms that the authentication token is accepted by the device.
cgd1 sync-time AA:BB:CC:DD:EE:FF
3. Read Alarms
cgd1 alarm-list AA:BB:CC:DD:EE:FF
4. Set an Alarm
cgd1 alarm-set AA:BB:CC:DD:EE:FF 3 07:30 --repeat 3e --no-snooze
This sets alarm slot 3 to 07:30, repeating on weekdays (Mon–Fri), with snooze disabled.
5. Monitor Sensors
cgd1 monitor AA:BB:CC:DD:EE:FF --duration 60
Streams temperature and humidity data for 60 seconds.
Running Tests
# All tests (unit + integration)
cargo test --all
# Only core library tests
cargo test -p cgd1-rs
# CLI integration tests
cargo test -p cgd1-rs-cli
# WebSocket integration tests
cargo test -p cgd1-rs-ws
All 240 tests run without hardware. Hardware-dependent tests are behind #[ignore] and can be run with cargo test -- --ignored.
Building the Documentation
cd book
mdbook build
The rendered HTML is placed in book/book/. Open book/book/index.html in a browser, or run mdbook serve for live preview during development.
Architecture
Workspace Layout
cgd1-rs/
├── cgd1-rs/ # Core library
│ └── src/
│ ├── ble/ # BLE transport layer
│ ├── command/ # Protocol commands, alarm, settings types
│ ├── error/ # Error types (transport, auth, clock)
│ ├── token/ # Auth token + file store
│ ├── types/ # Newtypes (MAC, temperature, humidity, etc.)
│ ├── device.rs # ClockDevice: connected device handle
│ ├── event.rs # ClockEvent enum
│ ├── manager.rs # ClockManager: multi-device connection manager
│ └── scanner.rs # ClockScanner: device discovery
├── cgd1-rs-cli/ # Command-line tool
├── cgd1-rs-controller/ # GTK 4 desktop application
├── cgd1-rs-ws/ # WebSocket + REST server
└── book/ # This documentation
Core Library (cgd1-rs)
BLE Transport Layer
The BleTransport trait abstracts all BLE operations, enabling testing with MockBleTransport and VirtualClockTransport without real hardware.
classDiagram
class BleTransport {
<<trait>>
+scan_active(duration) Vec~DiscoveredDevice~
+connect(address) Result
+disconnect(address) Result
+subscribe(characteristic) Result
+write_frame(command, payload) Result
+read(characteristic) Vec~u8~
+request_mtu(mtu) u16
}
class BtleplugTransport {
+new() Self
}
class MockBleTransport
class VirtualClockTransport
BleTransport <|.. BtleplugTransport
BleTransport <|.. MockBleTransport
BleTransport <|.. VirtualClockTransport
BtleplugTransport - Real hardware backend using the btleplug crate. Works on Linux (BlueZ), macOS (CoreBluetooth), and Windows.
MockBleTransport - In-memory mock with channels for advertisements and notifications. Used in unit tests.
VirtualClockTransport - Full in-memory simulation of the CGD1 device. Responds to commands with appropriate ACKs, maintains alarm/settings state, and emits sensor notifications. Used in integration tests for CLI and WebSocket server.
Device Lifecycle
flowchart LR
Scan["ClockScanner<br/>.scan_active()"] --> Connect["ClockManager<br/>.connect(mac)"]
Connect --> Auth["ClockDevice<br/>.authenticate(token)"]
Auth --> SyncTZ["ClockDevice<br/>.sync_timezone()"]
SyncTZ --> SyncTime["ClockDevice<br/>.sync_time_now()"]
SyncTime --> Ready["Ready for commands"]
Ready --> Operate["read_alarms<br/>set_alarm<br/>read_settings<br/>write_settings<br/>upload_ringtone<br/>..."]
Ready --> Monitor["subscribe() →<br/>ClockEvent stream"]
The connect_authenticate_and_sync method performs all steps in sequence. sync_timezone reads the device settings, computes the local system UTC offset via chrono::Local, and writes the correct timezone before sync_time to avoid time display offsets (the device defaults to UTC+8 after factory reset).
ClockDevice
ClockDevice is the primary handle for interacting with a connected CGD1. It owns:
- Transport reference - For sending commands and reading data
- Pending ACK map -
HashMap<u8, VecDeque<oneshot::Sender>>for matching ACKs to pending requests by command byte - Pending data response channel -
mpsc::Senderfor multi-packet responses (e.g., alarm read, settings read) - Event broadcast sender -
broadcast::Sender<ClockEvent>for sensor, battery, and connection events - Auth token - Stored after successful authentication
- Notification task handle -
JoinHandlestored so it can be aborted on disconnect, preventing zombie tasks from stealing notifications - Token store - Optional
Arc<dyn TokenStore>for persisting auth tokens after privileged commands succeed
Notification Task
A background task per connected device listens for BLE notifications and dispatches them:
flowchart TB
NotifyTask["Notification Task"]
AuthNotify["Auth Notify<br/>(ACKs)"]
DataNotify["Data Notify<br/>(ACKs, Settings, Alarms)"]
SensorNotify["Sensor Notify<br/>(Temp, Humidity)"]
EventChannel["broadcast::Sender<br/><ClockEvent>"]
AckMap["Oneshot Senders<br/>(pending ACKs)"]
DataChannel["mpsc::Sender<br/>(pending data)"]
NotifyTask --> AuthNotify
NotifyTask --> DataNotify
NotifyTask --> SensorNotify
AuthNotify --> AckMap
DataNotify --> AckMap
DataNotify --> DataChannel
SensorNotify --> EventChannel
Note: Battery is not sourced from GATT notifications. The GATT Battery Service (
0x2A19) returns an unreliable 99% on the CGD1. Battery data comes exclusively from advertising packets, cached viaKnownDeviceStore. See Sensors & Battery for details.
Request-Response Pattern
Commands that expect an ACK use a oneshot::channel. The pending request is registered before the command frame is sent to prevent a race condition where the ACK arrives before the receiver is set up:
prepare_ack(command)- Registers aoneshot::Senderin the pending maptransport.write_frame(command, payload)- Sends the commandwait_ack(receiver, timeout)- Waits for the ACK with a 10-second timeout
Multi-packet responses (e.g., read_alarms, read_settings) use an mpsc::Sender instead, allowing the notification task to forward data packets as they arrive.
Reconnection
If the device disconnects unexpectedly, the library attempts reconnection with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s capped). After a successful BLE reconnect, the full state recovery sequence is performed:
- Transport cleanup -
transport.disconnect()to clear stale connection state - BLE Reconnect via
transport.connect(address) - GATT Re-subscription for Auth Notify, Data Notify, and Sensor Notify
- Re-authentication using the stored token
- Flag reset -
is_authenticatedset totrue
ClockEvent::Disconnected and ClockEvent::Reconnected are broadcast to subscribers.
KnownDeviceStore
The KnownDeviceStore persists known device MAC addresses and battery levels:
known_devices.json- List of previously seen device MAC addresses, used to populate the controller dropdown on startup.battery_cache.json- Map of MAC address to battery level (u8), populated from advertising scans. Loaded on startup into the controller’sscan_battery_cache.
Both files reside in the platform-dependent data directory (e.g., ~/.local/share/cgd1-rs/ on Linux).
Error Handling
The library uses structured error types:
TransportError- BLE-level errors (not connected, characteristic not found, timeout, etc.)ClockError- Library-level errors (auth failed, command rejected, timeout, invalid alarm slot, parse errors, IO errors)AuthFailedError- Authentication failure with context (reason, is_new_token, token_path)
All errors implement std::error::Error and Display. The CLI additionally uses miette::Diagnostic for rich terminal output.
Newtypes
The library uses newtypes for compile-time validation at the API boundary:
| Type | Validation | Used By |
|---|---|---|
MacAddress | 6-byte MAC, colon-separated hex | All commands |
ScanDuration | 1–600 seconds | scan |
AlarmSlotIndex | 0–15 | alarm-set, alarm-delete |
ClockTime | HH:MM (0–23, 0–59) | alarm-set |
DayMask | u8 bitmask | alarm-set |
Brightness | 0–150, multiple of 10 | brightness, settings-write |
Volume | 1–5 | settings-write, ringtone-preview |
Timezone | -720 to +840 minutes | settings-write |
RingtoneSignature | 4 bytes or named slot | ringtone-upload |
All newtypes implement FromStr for CLI/JSON parsing and Display for output.
Frontends
CLI (cgd1-rs-cli)
Each subcommand connects, authenticates, executes one operation, and disconnects. The repl subcommand keeps a persistent connection. See CLI Tool.
GTK 4 Controller (cgd1-rs-controller)
A desktop application with a sidebar for device management and tabbed views for sensors, alarms, and settings. Uses glib::MainContext::spawn_local to bridge async BLE operations with the GTK event loop. See Controller.
WebSocket Server (cgd1-rs-ws)
An Axum-based server exposing a WebSocket endpoint for command/response and event streaming, plus REST endpoints for read operations. See WebSocket Server.
BLE Protocol
This chapter summarizes the Qingping CGD1 BLE protocol as implemented by cgd1-rs. For the full reverse-engineered specification, see BLE.md.
GATT Service & Characteristics
Custom Primary Service
22210000-554a-4546-5542-46534450466d
Characteristics
| Name | UUID | Direction |
|---|---|---|
| Auth Write | 00000001-0000-1000-8000-00805f9b34fb | Host → Device |
| Auth Notify | 00000002-0000-1000-8000-00805f9b34fb | Device → Host |
| Data Write | 0000000b-0000-1000-8000-00805f9b34fb | Host → Device |
| Data Notify | 0000000c-0000-1000-8000-00805f9b34fb | Device → Host |
| Sensor Notify | 00000100-0000-1000-8000-00805f9b34fb | Device → Host |
Standard Services
| Service | UUID | Characteristic | UUID | Format |
|---|---|---|---|---|
| Battery | 0x180f | Battery Level | 0x2a19 | 1 byte (0–100%) |
Frame Format
Every frame follows the same structure:
Request: [Length] [Command] [Payload...]
ACK: 04 ff [Command] [Status] [Payload 1B]
The length byte counts the bytes that follow it. An ACK is always exactly 5 bytes: 04 ff [Command] [Status] [Payload]. Status 00 means success.
Command Summary
| Length | Command | Operation | Characteristic |
|---|---|---|---|
0x11 | 0x01 | Auth Init | Auth Write |
0x11 | 0x02 | Auth Confirm | Auth Write |
0x05 | 0x09 | Time Sync | Auth Write |
0x01 | 0x0d | Read Firmware | Auth Write |
0x01 | 0x02 | Read Settings | Data Write |
0x13 | 0x01 | Set Settings | Data Write |
0x02 | 0x03 | Set Brightness | Data Write |
0x01 | 0x04 | Preview Ringtone (current vol) | Data Write |
0x02 | 0x04 | Preview Ringtone (specific vol) | Data Write |
0x01 | 0x06 | Read Alarms | Data Write |
0x07 | 0x05 | Set/Delete Alarm | Data Write |
0x08 | 0x10 | Audio Init | Data Write |
0x81 | 0x08 | Audio Data Packet | Data Write |
Connection Lifecycle
graph TD
Scan["Scan (FDCD)"] --> Connect["Connect (GATT)"]
Connect --> Auth["Auth (Token)"]
Auth --> SyncTime["Sync Time"]
SyncTime --> ReadConfig["Read Config / Sensors"]
ReadConfig --> Operate["Operate Alarms / Settings"]
Operate --> Idle["Idle"]
Idle --> Disconnect["Disconnect"]
Disconnect --> Scan
Passive vs Connected
- Passive (no connection): Sensor data (temperature, humidity, battery) via BLE advertisements with
FDCDservice-data UUID. No authentication required. - Connected: Authentication required for all write operations. Sensor data also available via real-time notify characteristic. Battery via standard GATT battery service.
For protocol details on each operation, see the dedicated chapters: Authentication, Alarms, Settings, Sensors & Battery, Audio Upload.
Scanning & Connecting
Device Discovery
The CGD1 broadcasts BLE advertisements with the ClearGrass/Qingping service-data UUID 0xFDCD. These advertisements carry sensor data (temperature, humidity, battery) and the device MAC address, allowing discovery without a connection.
Scanning with the CLI
cgd1 scan --duration 10
The --duration flag accepts values from 1 to 600 seconds (default: 10).
Output:
Scanning for 10s...
Found 1 device(s):
MAC: AA:BB:CC:DD:EE:FF
Temperature: 23.4 C
Humidity: 45.6 %
Battery: 87 %
Scanning with the Library
#![allow(unused)]
fn main() {
use cgd1_rs::{BtleplugTransport, ClockScanner, BleTransport};
use std::sync::Arc;
use std::time::Duration;
let transport = Arc::new(BtleplugTransport::new().await?);
let scanner = ClockScanner::new(transport);
let devices = scanner.scan_active(Duration::from_secs(10)).await?;
for device in &devices {
println!("MAC: {}", device.address);
if let Some(ad) = &device.advertisement {
println!(" Temperature: {:.1} C", ad.temperature.value());
println!(" Humidity: {:.1} %", ad.humidity.value());
println!(" Battery: {} %", ad.battery.value());
}
}
}
Advertisement Data
The AdvertisementData struct is parsed from the raw service-data payload:
| Field | Type | Scaling |
|---|---|---|
| MAC | 6 bytes (reversed) | - |
| Temperature | Int16 BE | / 10 (°C) |
| Humidity | UInt16 BE | / 10 (%) |
| Battery | UInt8 | & 0x7F (mask bit 7) |
Connecting
Connection Flow
sequenceDiagram
participant App as Application
participant Manager as ClockManager
participant Transport as BleTransport
participant CGD1 as CGD1 Device
App->>Manager: connect_authenticate_and_sync(mac, token)
Manager->>Transport: connect(address)
Transport->>CGD1: BLE connection
CGD1-->>Transport: Connected
Manager->>Transport: subscribe(Auth/Data/Sensor Notify)
Manager->>Manager: spawn notification task
Manager->>Manager: set_token_store(token_store)
Manager->>CGD1: authenticate(token)
CGD1-->>Manager: Auth ACKs
Manager->>CGD1: sync_timezone()
CGD1-->>Manager: Settings response
Manager->>CGD1: sync_time_now()
CGD1-->>Manager: TimeSync ACK
Manager-->>App: ClockDevice (ready)
The full connect_authenticate_and_sync flow performs:
- BLE connect -
transport.connect(address) - Subscribe to Auth Notify, Data Notify, and Sensor Notify characteristics
- Spawn notification task - Background task for processing BLE notifications
- Authenticate - Two-step token handshake (Auth Init + Auth Confirm)
- Sync timezone - Read device settings, compute local UTC offset, write correct timezone
- Sync time - Send current Unix timestamp; token is persisted only after this succeeds
Connecting with the Library
#![allow(unused)]
fn main() {
use cgd1_rs::{BtleplugTransport, ClockManager, MacAddress};
use std::sync::Arc;
let transport = Arc::new(BtleplugTransport::new().await?);
let manager = ClockManager::new(transport);
let mac: MacAddress = "AA:BB:CC:DD:EE:FF".parse()?;
let device = manager.connect(&mac).await?;
}
ClockManager tracks connected devices by MAC address. Calling connect on an already-connected device returns the existing handle.
Multi-Device Management
ClockManager supports simultaneous connections to multiple CGD1 devices:
#![allow(unused)]
fn main() {
let device_a = manager.connect(&mac_a).await?;
let device_b = manager.connect(&mac_b).await?;
// Both devices are now connected and can be operated independently
device_a.sync_time_now().await?;
device_b.read_alarms().await?;
}
Disconnecting
#![allow(unused)]
fn main() {
manager.disconnect(&mac).await?;
}
This aborts the notification task (via JoinHandle::abort()) and tears down the BLE connection. Aborting the notification task is critical - without it, a zombie task from a failed connection can steal notifications from a subsequent connection to the same device.
Automatic Reconnection
When the BLE connection drops (e.g., device goes out of range, battery dies, or alarm triggers a disconnect), the notification task automatically attempts reconnection with exponential backoff:
- Disconnect detected - The notification stream ends or a
CentralEvent::DeviceDisconnectedis received - Transport cleanup -
transport.disconnect()clears the connection state to avoidAlreadyConnectederrors on retry - Reconnect attempts - Up to 10 attempts with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s capped)
- BLE connect + subscribe -
reconnect_and_restorereconnects and re-subscribes to all notify characteristics - Re-authentication - A separate task re-authenticates using the stored token (spawned concurrently so the notification loop can process ACKs)
- State recovery - On success,
ClockEvent::Reconnectedis emitted
The connect() call has a 10-second timeout to prevent hanging when the device is unavailable (e.g., during an alarm). The device typically becomes discoverable again ~15-20 seconds after an alarm-triggered disconnect.
Note: While the alarm is sounding, the CGD1 is not discoverable. Dismissing the alarm quickly allows faster reconnection.
Virtual Backend (Testing)
For testing without hardware, use the virtual backend:
cgd1 --backend virtual scan
cgd1 --backend virtual sync-time AA:BB:CC:DD:EE:FF
The virtual backend simulates a CGD1 device in memory, responding to all commands with appropriate ACKs and maintaining alarm/settings state.
Authentication
The CGD1 uses a two-step token handshake on the Auth characteristics. Once paired, the same 16-byte token must be used for all future connections.
Protocol
sequenceDiagram
participant App as Application
participant Device as ClockDevice
participant Transport as BleTransport
participant CGD1 as CGD1 Device
App->>Device: authenticate(token)
Device->>Transport: subscribe(Auth Notify)
Device->>Transport: write(Auth Write, 11 01 [Token 16B])
Transport->>CGD1: GATT write
CGD1-->>Transport: notification (04 ff 01 00 [Payload])
Transport-->>Device: Ack { command: 01, status: 00 }
Device->>Transport: write(Auth Write, 11 02 [Token 16B])
Transport->>CGD1: GATT write
CGD1-->>Transport: notification (04 ff 02 00 00)
Transport-->>Device: Ack { command: 02, status: 00 }
Device-->>App: Ok (authenticated)
Steps
- Subscribe to Auth Notify (
00000002-...) - Auth Init: Send
11 01 [Token 16B]to Auth Write - Wait for ACK:
04 ff 01 00 [Payload](status00= success) - Auth Confirm: Send
11 02 [Token 16B]to Auth Write - Wait for final ACK:
04 ff 02 00 00
Token Management
Token Generation
A new 16-byte random token is generated for first-time pairing using rand::random().
Token Persistence
Tokens are stored in a file-based store, keyed by MAC address. The default directory is platform-dependent (via dirs crate):
- Linux:
~/.local/share/cgd1-rs/tokens/ - macOS:
~/Library/Application Support/cgd1-rs/tokens/
Persistence Rule
A newly generated token is only persisted after a privileged command (e.g.,
sync_time) succeeds. An Auth Confirm ACK alone does not prove the token was accepted - the device may send an ACK even with a bad token.
If sync_time times out after successful Auth ACKs, the device has a previously stored token that doesn’t match. A factory reset is required - see Troubleshooting: Factory Reset.
The sync-time CLI command uses connect_with_store, which persists the token only after sync_time_now() succeeds:
#![allow(unused)]
fn main() {
let (connection, store) = DeviceConnection::connect_with_store(&args.address).await?;
connection.device().sync_time_now().await?;
if token_result.is_new() {
store.save(&args.address, &token_result)?;
}
}
Auth Failure
If authentication fails, AuthFailedError provides actionable context:
| Field | Description |
|---|---|
reason | Human-readable failure reason |
is_new_token | Whether the token was newly generated (not yet paired) |
token_path | Filesystem path where the token would be stored |
The CLI renders this as a miette diagnostic with suggestions.
Using the Library
#![allow(unused)]
fn main() {
use cgd1_rs::{BtleplugTransport, ClockManager, FileTokenStore, MacAddress, TokenStore};
use std::sync::Arc;
let transport = Arc::new(BtleplugTransport::new().await?);
let manager = ClockManager::new(transport.clone());
let device = manager.connect(&mac).await?;
let store = Arc::new(FileTokenStore::default_directory());
let token_result = store.load_or_generate(&mac);
device.set_token_store(store.clone() as Arc<dyn TokenStore>);
device.authenticate(&token_result).await?;
// Token is confirmed only after a privileged command succeeds
device.sync_timezone().await?; // optional but recommended
device.sync_time_now().await?;
if token_result.is_new() {
store.save(&mac, &token_result)?;
}
}
Firmware Version
After authentication, the firmware version can be queried:
#![allow(unused)]
fn main() {
let firmware: String = device.read_firmware().await?;
println!("Firmware: {}", firmware);
}
Protocol: Send 01 0d to Auth Write, receive 0b [Byte] [ASCII String] on Auth Notify.
Known firmware versions: 1.0.1_0046, 1.0.1_0063, 1.0.1_0067, 1.0.1_0126, 1.0.1_0130, 1.0.1_0132.
Alarms
The CGD1 supports up to 16 independent alarm slots (indexed 0–15). Each alarm has a time, day-of-week repeat mask, and snooze setting.
Alarm Structure
AlarmEntry (5 bytes)
[Enabled] [HH] [MM] [Days] [Snooze]
| Field | Bytes | Description |
|---|---|---|
| Enabled | 1 | 0x01 = on, 0x00 = off |
| HH | 1 | Hour (0–23) |
| MM | 1 | Minute (0–59) |
| Days | 1 | Day bitmask (see below) |
| Snooze | 1 | 0x01 = on, 0x00 = off |
An empty/unused slot has all bytes set to 0xFF: FF FF FF FF FF.
DayMask Bitmask
| Bit | Value | Day |
|---|---|---|
| 0 | 0x01 | Monday |
| 1 | 0x02 | Tuesday |
| 2 | 0x04 | Wednesday |
| 3 | 0x08 | Thursday |
| 4 | 0x10 | Friday |
| 5 | 0x20 | Saturday |
| 6 | 0x40 | Sunday |
| - | 0x00 | Once (no repeat) |
Common patterns:
| Name | Value | Days |
|---|---|---|
| Every day | 0x7F | Mon–Sun |
| Weekdays | 0x3E | Mon–Fri |
| Weekends | 0x41 | Sat–Sun |
Protocol
Set Alarm
Send 07 05 [ID] [Enabled] [HH] [MM] [Days] [Snooze] to Data Write.
ACK: 04 ff 05 00 00 (success)
Delete Alarm
Overwrite the slot with FF values: 07 05 [ID] FF FF FF FF FF
ACK: 04 ff 05 00 00 (success)
Read Alarms
Send 01 06 to Data Write.
Response: The device sends 6 packets on Data Notify, each carrying 3 alarm entries:
11 06 [Base Index] [Entry 1 (5B)] [Entry 2 (5B)] [Entry 3 (5B)]
Each packet is 18 bytes. All 16 slots are returned (empty slots have FF FF FF FF FF).
CLI Usage
Read all alarms
cgd1 alarm-list AA:BB:CC:DD:EE:FF
Set an alarm
cgd1 alarm-set AA:BB:CC:DD:EE:FF 3 07:30 --repeat 3e
| Argument | Description |
|---|---|
address | Device MAC address |
slot | Slot index 0–15 |
time | Alarm time in HH:MM format |
--repeat | Day mask as hex (default: 7f = every day) |
--no-snooze | Disable snooze for this alarm |
Delete an alarm
cgd1 alarm-delete AA:BB:CC:DD:EE:FF 3
Library API
#![allow(unused)]
fn main() {
use cgd1_rs::{AlarmSlotIndex, ClockTime, DayMask};
// Set an alarm
device.set_alarm(
AlarmSlotIndex::new(3)?,
ClockTime::new(7, 30)?,
DayMask::WEEKDAYS,
true, // enabled
true, // snooze
).await?;
// Read all alarms
let slots = device.read_alarms().await?;
for slot in &slots {
if slot.is_empty() {
continue;
}
println!(
"Slot {}: {:02}:{:02} repeat={:#04x} enabled={} snooze={}",
slot.index(),
slot.entry().hour(),
slot.entry().minute(),
slot.entry().day_mask(),
slot.entry().enabled(),
slot.entry().snooze(),
);
}
// Delete an alarm
device.delete_alarm(AlarmSlotIndex::new(3)?).await?;
}
DayMask Constants
The DayMask newtype provides common constants:
| Constant | Value | Description |
|---|---|---|
DayMask::ONCE | 0x00 | No repeat (one-shot) |
DayMask::EVERY_DAY | 0x7F | Monday through Sunday |
DayMask::WEEKDAYS | 0x3E | Monday through Friday |
DayMask::WEEKENDS | 0x41 | Saturday and Sunday |
Device Settings
The CGD1 stores all configuration in a single 18-byte settings payload, read and written via the Data characteristics.
Settings Payload
13 01 [Vol] [Hdr1] [Hdr2] [Flags] [TZ] [Duration] [Brightness] [NightStartH] [NightStartM] [NightEndH] [NightEndM] [TzSign] [NightEn] [Reserved] [Sig 4B]
| Offset | Field | Bytes | Description |
|---|---|---|---|
| 0 | Header | 1 | 0x13 (length byte) |
| 1 | Command | 1 | 0x01 (set) or 0x02 (read response) |
| 2 | Volume | 1 | Sound volume (1–5) |
| 3 | Hdr1 | 1 | Fixed 0x58 |
| 4 | Hdr2 | 1 | Fixed 0x02 |
| 5 | Flags | 1 | Mode bitfield (see below) |
| 6 | Timezone | 1 | Offset in 6-minute units (minutes / 6) |
| 7 | Duration | 1 | Screen light duration in seconds |
| 8 | Brightness | 1 | Packed brightness (see below) |
| 9 | NightStartH | 1 | Night mode start hour (0–23) |
| 10 | NightStartM | 1 | Night mode start minute (0–59) |
| 11 | NightEndH | 1 | Night mode end hour (0–23) |
| 12 | NightEndM | 1 | Night mode end minute (0–59) |
| 13 | TzSign | 1 | 0x01 = positive, 0x00 = negative |
| 14 | NightEn | 1 | 0x01 = enabled, 0x00 = disabled |
| 15 | Reserved | 1 | Set to 0xFF |
| 16–19 | Signature | 4 | Ringtone signature (0xFFFFFFFF when unused) |
Flags Bitfield (Byte 5)
| Bit | Mask | Field | 0 | 1 |
|---|---|---|---|---|
| 0 | 0x01 | Language | Chinese | English |
| 1 | 0x02 | Time Format | 24-hour | 12-hour |
| 2 | 0x04 | Temperature Unit | Celsius | Fahrenheit |
| 4 | 0x10 | Alarms | Enabled | Disabled |
Brightness Encoding (Byte 8)
Two nibbles packed into one byte:
High nibble = daytime_brightness / 10
Low nibble = nighttime_brightness / 10
Each value must be 0–150 and a multiple of 10. Typical range is 0–100.
Example: Daytime 80%, Nighttime 30% → (8 << 4) | 3 = 0x83
Night Mode Workaround
Disabling night mode is done by setting a 1-minute night mode window (00:00–00:01). Even the official app does this.
Protocol
Read Settings
Send 01 02 to Data Write. Response on Data Notify: 13 02 [Settings Payload 18B]
Write Settings
Send 13 01 [Settings Payload 18B] to Data Write. ACK: 04 ff 01 00 00
Set Immediate Brightness (Preview)
Send 02 03 [Value] to Data Write, where Value is brightness / 10 (0–15).
ACK: 04 ff 03 00 00
Preview Ringtone
Plays a generic beep sound for testing volume.
- Current volume:
01 04 - Specific volume:
02 04 [Vol](volume 1–5)
ACK: 04 ff 04 00 00
CLI Usage
Read settings
cgd1 settings-read AA:BB:CC:DD:EE:FF
Write settings
Only specified fields are updated; unspecified fields are read from the device first and preserved:
cgd1 settings-write AA:BB:CC:DD:EE:FF \
--volume 3 \
--brightness 80 \
--night-brightness 30 \
--timezone 60 \
--time-format 24 \
--temp-unit C \
--language en
| Flag | Values | Description |
|---|---|---|
--volume | 1–5 | Sound volume |
--brightness | 0–150 (multiple of 10) | Daytime brightness |
--night-brightness | 0–150 (multiple of 10) | Nighttime brightness |
--timezone | -720 to +840 (minutes) | Timezone offset |
--time-format | 12 or 24 | Time display format |
--temp-unit | C or F | Temperature unit |
--language | en, zh, de, ja | Display language |
Set brightness (preview)
cgd1 brightness AA:BB:CC:DD:EE:FF 80
Preview ringtone
cgd1 ringtone-preview AA:BB:CC:DD:EE:FF --volume 3
Library API
#![allow(unused)]
fn main() {
use cgd1_rs::{Brightness, Language, TemperatureUnit, TimeFormat, Timezone, Volume};
// Read current settings
let settings = device.read_settings().await?;
println!("Volume: {}", settings.volume);
println!("Brightness: {}", settings.brightness);
// Modify and write
let mut settings = device.read_settings().await?;
settings.volume = Volume::new(3)?;
settings.brightness = Brightness::new(80)?;
settings.time_format = TimeFormat::TwentyFourHour;
settings.temperature_unit = TemperatureUnit::Celsius;
settings.language = Language::English;
device.write_settings(&settings).await?;
// Set immediate brightness (preview)
device.set_brightness(Brightness::new(80)?).await?;
// Preview ringtone
device.preview_ringtone(Some(Volume::new(3)?)).await?;
}
Sensors & Battery
The CGD1 provides temperature, humidity, and battery data through three independent channels.
Data Sources
| Mode | Data | Source | Requires Connection |
|---|---|---|---|
| Passive | Temperature, humidity, battery | BLE advertisements (FDCD) | No |
| Connected | Temperature, humidity (real-time) | Sensor Notify (00000100-...) | Yes |
| Connected | Battery (cached from advertising) | KnownDeviceStore battery cache | No (cached) |
| On-demand | Battery (unreliable) | GATT Battery Service (0x180f / 0x2a19) | Yes |
Warning: The GATT Battery Service characteristic (
0x2A19) consistently returns 99% on the CGD1 and is not reliable. The controller and recommended connect flow use advertising-based battery data exclusively. Theread_battery()method remains available for CLI/WS diagnostic use but is not used in the connect flow.
Passive Sensor Stream (Advertising)
The device broadcasts sensor data in BLE advertisement packets via Service Data under UUID 0xFDCD.
Format
[08|88] 0C [MAC 6B] 01 04 [Temp 2B] [Humidity 2B] 02 01 [Battery]
| Field | Type | Scaling |
|---|---|---|
| Temperature | Int16 LE | / 10.0 (°C) |
| Humidity | UInt16 LE | / 10.0 (%RH) |
| Battery | UInt8 | & 0x7F (mask bit 7) |
Note: The passive advertisement stream uses a scaling of / 10 (per Theengs decoder). The connected sensor stream uses / 100.0 (per clOwOck). This discrepancy may be firmware-dependent.
Parsing
The AdvertisementData::parse method extracts temperature, humidity, battery, and MAC address from the raw service-data payload. This is used by ClockScanner during active scanning.
Connected Sensor Stream (Notifications)
After connecting, the device sends real-time sensor data via the Sensor Notify characteristic (00000100-0000-1000-8000-00805f9b34fb).
Format
[00] [Temp L] [Temp H] [Hum L] [Hum H]
5 bytes, starting with a constant 00. This stream does not follow the length-byte framing.
| Field | Type | Scaling |
|---|---|---|
| Temperature | Signed Int16 LE | / 100.0 (°C) |
| Humidity | Unsigned UInt16 LE | / 100.0 (%RH) |
Event Dispatch
The notification task parses sensor notifications and broadcasts ClockEvent::SensorUpdate. Battery is not included in sensor notifications (the CGD1 sends only 5 bytes without battery data):
#![allow(unused)]
fn main() {
pub enum ClockEvent {
SensorUpdate { temperature: Temperature, humidity: Humidity },
BatteryLevel { level: BatteryLevel },
Disconnected,
Reconnected,
Ack { command: u8, status: AckStatus },
Advertisement(AdvertisementData),
}
}
ClockEvent::BatteryLevel is sent from the controller’s connect handler using the advertising battery cache, not from sensor notifications.
Battery (Advertising Cache)
The CGD1 only advertises battery data when not connected and the button is held for 3 seconds. The battery level is encoded in advertising TLV type 0x02 as a single byte (masked with 0x7F).
KnownDeviceStore Battery Cache
The KnownDeviceStore persists battery levels from advertising scans in a separate battery_cache.json file, keyed by MAC address:
{"58:2d:34:82:cc:81": 31}
save_battery(address, level)- Called by the controller’s scan callback when advertising battery data is received.load_battery()- Called on startup to populate the in-memoryscan_battery_cache.
Connect Flow Integration
The controller’s connect handler reads the cached battery value from scan_battery_cache after a successful connect_authenticate_and_sync and sends ClockEvent::BatteryLevel to update the UI:
#![allow(unused)]
fn main() {
if let Some(level) = scan_battery_cache.lock().unwrap().get(&addr).copied() {
let _ = event_tx.send(ClockEvent::BatteryLevel {
level: BatteryLevel::new(level),
});
}
}
Device Behavior Notes
- The device stops advertising when connected, so battery data is only available passively.
- Advertising battery values can fluctuate between packets (e.g., 4%, 22%, 31% in the same scan window).
- Sensor notifications (5 bytes) do not contain battery data.
- Device settings responses do not contain battery data.
Battery (GATT - Diagnostic Only)
The read_battery() method reads the standard GATT Battery Service characteristic (0x2A19). This is available for CLI and WebSocket diagnostic use but is not used in the controller connect flow because it consistently returns 99% on the CGD1.
#![allow(unused)]
fn main() {
// Diagnostic use only - unreliable on CGD1
let battery = device.read_battery().await?;
}
CLI Usage
Monitor sensors
cgd1 monitor AA:BB:CC:DD:EE:FF --duration 60
Streams temperature and humidity in real-time. Use --duration 0 (default) for indefinite monitoring.
Output:
Monitoring AA:BB:CC:DD:EE:FF for 60s...
[2024-01-15T10:30:00] Temperature: 23.4 C Humidity: 45.6 %
[2024-01-15T10:30:05] Temperature: 23.5 C Humidity: 45.4 %
...
Read battery
cgd1 battery AA:BB:CC:DD:EE:FF
Output:
Battery: 87 %
Library API
Subscribe to events
#![allow(unused)]
fn main() {
use cgd1_rs::ClockEvent;
let mut receiver = device.subscribe();
while let Ok(event) = receiver.recv().await {
match event {
ClockEvent::SensorUpdate { temperature, humidity } => {
println!("Temperature: {:.1} C Humidity: {:.1} %",
temperature.value(), humidity.value());
}
ClockEvent::BatteryLevel { level } => {
println!("Battery: {} %", level.value());
}
ClockEvent::Disconnected => {
println!("Device disconnected");
break;
}
ClockEvent::Reconnected => {
println!("Device reconnected");
}
_ => {}
}
}
}
Read battery directly
#![allow(unused)]
fn main() {
let battery = device.read_battery().await?;
println!("Battery: {} %", battery);
}
Audio Upload
The CGD1 supports uploading custom ringtones via a block-based BLE transfer protocol. Audio is sent as 8-bit unsigned PCM at 8 kHz mono.
Audio Format
| Property | Value |
|---|---|
| Format | 8-bit unsigned PCM |
| Sample rate | 8000 Hz |
| Channels | Mono |
| Max size | ~98 KB (~12 seconds) |
| Padding | Multiple of 512 bytes (00 end marker + FF fill) |
Ringtone Signatures
Built-in Ringtones
The original Qingping/ClearGrass PCM ringtones have been replaced with audio from lomiri-sounds (CC-BY-SA-3.0) and a chiptune remix by Dubmood (CC-BY-NC-SA-4.0). See LICENSE_RINGTONES.md for full attribution and license details.
| Signature | Name | Source | Copyright holder | License | PCM length |
|---|---|---|---|---|---|
fdc366a5 | Beep | Alarm clock.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 95967 |
0961bb77 | Digital Ringtone | Mallet.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 18155 |
ba2c2c8c | Digital Ringtone 2 | Sintonia.ogg | 2018, Mauricio Duarte | CC-BY-4.0 | 18462 |
ea2d4c02 | Cuckoo | Counterpoint.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 76522 |
791bacb3 | Telephone Ringtone | Call me.ogg | 2018, Anonymous | CC0-1.0 | 95967 |
1d019fd6 | Exotic Guitar | Latin.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
6e70b659 | Lively Piano | UBports.ogg | 2018, Mauricio Duarte | CC-BY-4.0 | 96000 |
8f004886 | Story Piano | Melody piano.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 86043 |
26522519 | Forest Piano | Mangore.ogg | 2018, Mauricio Duarte | CC-BY-4.0 | 69819 |
4d6f6e6b | Monkey Island | monkey-island-8bit | Kalle Jonsson (Dubmood) | CC-BY-NC-SA-4.0 | 80000 |
416c5379 | Alarm Synth | Alarm synth.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
41724d62 | Array Mbira | Array mbira.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 79033 |
426c6973 | Bliss | Bliss.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 47181 |
43656c73 | Celestial | Celestial.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
456e7472 | Entropy | Entropy.ogg | 2018, Mauricio Duarte | CC-BY-4.0 | 96000 |
476c4d61 | Glass Marimba | Glass marimba.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
48616c6f | Halo Pentatonic | Halo Pentatonic.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 87819 |
4861726d | Harmonics | Harmonics.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 64230 |
48617270 | Harp Arp | Harp arp.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 50077 |
4b6f746f | Koto Chords | Koto chords.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
53616b65 | Sakenointi | sakenointi.ogg | 2018, TMetso | CC-BY-4.0 | 78454 |
53616d73 | Sam’s Song | Sam’s Song.ogg | 2013, Sam Hulick | CC-BY-SA-3.0 | 38147 |
536f756c | Soul | Soul.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
53706172 | Sparkle | Sparkle.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 71332 |
53757072 | Supreme | Supreme.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 91793 |
53757275 | Suru Arpeggio | Suru arpeggio.ogg | 2013, Canonical Ltd. | CC-BY-SA-3.0 | 96000 |
54696d65 | Time Not Lost | Time not Lost.ogg | 2018, Mauricio Duarte | CC-BY-4.0 | 95944 |
576f6f64 | Wooden Drive | Wooden Drive.ogg | 2018, Amber Forest | CC-BY-3.0 | 56816 |
958f8a83 | Elysium | ELYSIUM.MOD | Jester | CC-BY-NC-SA-4.0 | 96000 |
Sources:
- lomiri-sounds: https://gitlab.com/ubports/development/core/lomiri-sounds
- Upstream copyright file: https://gitlab.com/ubports/development/core/lomiri-sounds/-/blob/main/debian/copyright?ref_type=heads
- Dubmood (Monkey Island): chiptune remix, CC-BY-NC-SA-4.0
- Full attribution and license details: LICENSE_RINGTONES.md
Custom Slots
Two alternating slot signatures are used for custom uploads:
| Signature | Name | Constant |
|---|---|---|
deaddead | Custom Slot A | RingtoneSignature::CustomSlotA |
beefbeef | Custom Slot B | RingtoneSignature::CustomSlotB |
Always alternate between slots when uploading new custom audio. The device may reject uploads if the target signature matches the currently active ringtone.
Custom Ringtones from ~/.config/cgd1-rs/ringtones/
The GTK controller automatically discovers user-provided ringtone files placed in the XDG config directory:
~/.config/cgd1-rs/ringtones/*.pcm
Each .pcm file appears as a separate entry in the Audio Editor’s ringtone
dropdown, using the filename (without extension) as the display name. WAV files
with a .pcm extension are also accepted — the controller automatically
extracts the raw PCM data from the data chunk if the file starts with
RIFF....WAVE.
How It Works
-
Signature derivation: The controller computes a deterministic 4-byte signature by hashing the filename. The hash avoids collisions with all known built-in and slot signatures by incrementing a salt until a non-colliding value is found. The resulting signature is stored as a
RingtoneSignature::Custom([u8; 4]). -
Upload on Apply: When the user selects a custom ringtone and clicks “Apply”, the controller reads the PCM file from disk, uploads the audio to the device under the derived signature, and writes that signature to the device settings to activate it.
-
Read-back: When reading settings from the device, if the device reports a
Customsignature that matches a known custom ringtone file, the dropdown automatically selects that entry. If the file no longer exists, the raw hex signature is displayed instead.
Adding a Custom Ringtone
# Create the directory if it doesn't exist
mkdir -p ~/.config/cgd1-rs/ringtones
# Copy your PCM file (8-bit unsigned, 8 kHz, mono, max. 98 KB)
cp my_ringtone.pcm ~/.config/cgd1-rs/ringtones/my_ringtone.pcm
Restart the controller (or re-open the Audio Editor panel) for the new ringtone to appear in the dropdown.
Note: Custom ringtones from the config directory use
RingtoneSignature::Customwith a derived hash signature, not the fixedCustomSlotA/CustomSlotBslots. The two fixed slots remain available for manual uploads via the “Custom Upload” section of the Audio Editor.
Upload Protocol
sequenceDiagram
participant App as Application
participant Device as ClockDevice
participant CGD1 as CGD1 Device
Note over App,CGD1: Step 0: Prepare audio
App->>App: Validate + pad to 512-byte multiple
Note over App,CGD1: Step 1: MTU Exchange
App->>CGD1: Request MTU 247
CGD1-->>App: Negotiated MTU
Note over App,CGD1: Step 2: Audio Init
App->>Device: upload_ringtone(audio, signature)
Device->>CGD1: 08 10 [Size 3B LE] [Signature 4B]
CGD1-->>Device: ACK 04 ff 10 00 [Payload]
Note over App,CGD1: Step 3: Audio Data (block-based)
loop Every 4 packets (512 bytes)
App->>CGD1: 81 08 [Audio 128B]
App->>CGD1: 81 08 [Audio 128B]
App->>CGD1: 81 08 [Audio 128B]
App->>CGD1: 81 08 [Audio 128B]
CGD1-->>App: ACK 04 ff 08 00 [Payload]
end
Note over App,CGD1: Step 4: Completion
Note over CGD1: Device stores audio under signature
Step 0 - Prepare the Payload
- Decode/resample the source file to 8-bit unsigned PCM, 8000 Hz, mono
- Pad to a multiple of 512 bytes: first padding byte is
00(end-of-audio marker), remaining areFF - Keep the total under ~98 KB
The validate_audio function checks these constraints and returns an error if they are violated.
Step 1 - MTU Exchange
Before uploading, an MTU exchange is performed to ensure the 130-byte packets (128 bytes audio + 2-byte header) fit within a single BLE packet:
#![allow(unused)]
fn main() {
let mtu = transport.request_mtu(247).await?;
if mtu < 130 {
return Err(ClockError::MtuTooSmall { mtu });
}
}
Step 2 - Audio Init
Send 08 10 [Size 3B LE] [Signature 4B] to Data Write.
- Size: Padded audio length in bytes (Little Endian, 3 bytes)
- Signature: Target ringtone slot signature
Wait for ACK: 04 ff 10 [Status] [Payload] (status 00 = success)
Step 3 - Send Audio Data
- Packet format:
81 08 [Audio 128B](130 bytes on the wire) - A trailing packet shorter than 128 bytes is padded with
FF - Packets per block: 4 (512 bytes of audio per block)
- After every 4th packet (or the last packet), wait for block ACK:
04 ff 08 [Status] [Payload] - Each packet is written with write-with-response
Step 4 - Completion
After the last block ACK, the device stores the audio under the given signature. Select it as the active ringtone by writing the same signature in the settings payload (bytes 16–19).
Important: The transfer must own the connection. Alarm reads, settings reads, RSSI polling, or notification re-subscriptions issued in parallel can abort the upload. The library holds a mutex for the whole transfer.
CLI Usage
cgd1 ringtone-upload AA:BB:CC:DD:EE:FF audio.pcm --signature CustomSlotA
| Argument | Description |
|---|---|
address | Device MAC address |
file | Path to 8-bit PCM audio file (8 kHz, mono) |
--signature | Ringtone name (CustomSlotA, CustomSlotB) or 4-byte hex (e.g., deadbeef) |
After uploading, select the ringtone by writing its signature to the device settings:
cgd1 settings-write AA:BB:CC:DD:EE:FF --volume 3
The CLI does not yet support writing the ringtone signature directly via
settings-write. Use the library API or the GTK controller for this.
Library API
#![allow(unused)]
fn main() {
use cgd1_rs::RingtoneSignature;
use std::path::Path;
// Upload from file
let audio = std::fs::read("audio.pcm")?;
device.upload_ringtone(&audio, RingtoneSignature::CustomSlotA).await?;
// Or upload from bytes
let audio: Vec<u8> = generate_pcm_audio();
device.upload_ringtone(&audio, RingtoneSignature::CustomSlotA).await?;
// Select as active ringtone
let mut settings = device.read_settings().await?;
settings.ringtone_signature = RingtoneSignature::CustomSlotA;
device.write_settings(&settings).await?;
}
CLI Tool
The cgd1 command-line tool provides access to all device operations through subcommands. It uses clap for argument parsing and miette for rich error diagnostics.
Installation
cargo install --path cgd1-rs-cli
Global Options
Options:
-v, --verbose... Verbosity level (-v, -vv, -vvv)
--backend <BACKEND> BLE backend: `btleplug` (real hardware) or `virtual` (in-memory) [default: btleplug]
-h, --help Print help
-V, --version Print version
The --backend virtual flag uses an in-memory simulation instead of real BLE hardware. This is useful for testing and demos without a device.
Subcommands
scan
Scan for nearby CGD1 devices.
cgd1 scan --duration 10
| Flag | Default | Range | Description |
|---|---|---|---|
-d, --duration | 10 | 1–600 | Scan duration in seconds |
Output includes MAC address, temperature, humidity, and battery from passive advertisements.
sync-time
Synchronize the device clock to the current system time. This is the recommended first command after connecting, as it confirms the authentication token is accepted.
cgd1 sync-time AA:BB:CC:DD:EE:FF
alarm-list
Read all 16 alarm slots from the device.
cgd1 alarm-list AA:BB:CC:DD:EE:FF
alarm-set
Set or modify an alarm at a specific slot.
cgd1 alarm-set AA:BB:CC:DD:EE:FF 3 07:30 --repeat 3e --no-snooze
| Argument | Description |
|---|---|
address | Device MAC address |
slot | Slot index 0–15 |
time | Alarm time in HH:MM format |
-r, --repeat | Day mask as hex (default: 7f = every day) |
--no-snooze | Disable snooze |
alarm-delete
Delete an alarm at a specific slot.
cgd1 alarm-delete AA:BB:CC:DD:EE:FF 3
settings-read
Read all device settings.
cgd1 settings-read AA:BB:CC:DD:EE:FF
settings-write
Write device settings. Only specified fields are updated; unspecified fields are preserved from the device.
cgd1 settings-write AA:BB:CC:DD:EE:FF \
--volume 3 \
--brightness 80 \
--night-brightness 30 \
--timezone 60 \
--time-format 24 \
--temp-unit C \
--language en
| Flag | Values | Description |
|---|---|---|
--volume | 1–5 | Sound volume |
--brightness | 0–150 (multiple of 10) | Daytime brightness |
--night-brightness | 0–150 (multiple of 10) | Nighttime brightness |
--timezone | -720 to +840 | Timezone offset in minutes |
--time-format | 12 or 24 | Time display format |
--temp-unit | C or F | Temperature unit |
--language | en, zh, de, ja | Display language |
brightness
Set immediate brightness (preview, not persisted).
cgd1 brightness AA:BB:CC:DD:EE:FF 80
ringtone-preview
Play a preview beep sound on the device.
cgd1 ringtone-preview AA:BB:CC:DD:EE:FF --volume 3
ringtone-upload
Upload a custom ringtone from a PCM file.
cgd1 ringtone-upload AA:BB:CC:DD:EE:FF audio.pcm --signature CustomSlotA
| Argument | Description |
|---|---|
address | Device MAC address |
file | Path to 8-bit PCM audio file (8 kHz, mono) |
-s, --signature | Ringtone name or 4-byte hex (default: CustomSlotA) |
firmware
Read the device firmware version.
cgd1 firmware AA:BB:CC:DD:EE:FF
battery
Read the device battery level.
cgd1 battery AA:BB:CC:DD:EE:FF
monitor
Monitor sensor data (temperature, humidity) in real-time.
cgd1 monitor AA:BB:CC:DD:EE:FF --duration 60
| Flag | Default | Description |
|---|---|---|
-d, --duration | 0 | Duration in seconds (0 = indefinite) |
repl
Start an interactive REPL session with a persistent connection. State changes (e.g., settings-write) are visible in subsequent commands (e.g., settings-read).
cgd1 repl --address AA:BB:CC:DD:EE:FF
If --address is omitted, use connect <mac> inside the REPL.
Available REPL commands mirror the CLI subcommands (without the cgd1 prefix):
cgd1> help
Available commands:
scan, sync-time, alarm-list, alarm-set, alarm-delete,
settings-read, settings-write, brightness, ringtone-preview,
ringtone-upload, firmware, battery, monitor, connect, disconnect, exit
Token Management
The CLI automatically manages authentication tokens via FileTokenStore. Tokens are stored per MAC address in the platform’s data directory:
- Linux:
~/.local/share/cgd1-rs/tokens/ - macOS:
~/Library/Application Support/cgd1-rs/tokens/
A new token is generated on first connection and persisted only after sync-time succeeds. Subsequent connections reuse the stored token.
If a token becomes invalid (e.g., the device was paired with a different app), delete the token file and run sync-time again to generate a new one.
Error Handling
The CLI uses miette for rich error diagnostics. Errors include context, source spans, and suggestions:
Error: Authentication failed
→ The device rejected the authentication token.
help: This may happen if the device was paired with a different app.
Delete the token file and try again:
rm ~/.local/share/cgd1-rs/tokens/AA_BB_CC_DD_EE_FF.bin
Verbosity
The -v flag controls log output:
| Level | Output |
|---|---|
| (none) | Errors only |
-v | Warnings + errors |
-vv | Info + warnings + errors |
-vvv | Debug (full trace) |
Controller
The cgd1-rs-controller crate is a GTK 4 desktop application for managing CGD1 devices. It provides a graphical interface for scanning, connecting, viewing sensor data, editing alarms, adjusting settings, and uploading ringtones.
Installation
# Install GTK 4 development libraries first (see Getting Started)
cargo build --release -p cgd1-rs-controller
Run the application:
cargo run --release -p cgd1-rs-controller
Architecture
flowchart TB
subgraph App["ClockControllerApp"]
Window["MainWindow<br/>(sidebar + device tabs)"]
subgraph Dialogs["Dialog System"]
Alarms["Alarms Dialog"]
Audio["Audio Dialog"]
Info["Info Dialog"]
Settings["Settings Dialog"]
end
Display["Display Module<br/>(Seven-segment clock)"]
end
Core["cgd1-rs<br/>(Core Library)"]
Device["CGD1 Device"]
Window --> Dialogs
Window --> Display
Window --> Core
Core --> Device
ClockControllerApp
The main gtk4::Application subclass. Manages the application lifecycle, window creation, and device connections.
MainWindow
The main window features a sidebar for device management and a tabbed content area. Each connected device gets its own tab showing:
- Clock display - Seven-segment style time display using a custom font
- Sensor cards - Temperature, humidity, and battery
- Device info - Firmware version, MAC address
Dialog System
Instead of separate widget files, the controller uses a modular dialog/ directory:
dialog/alarms.rs- Alarm editor dialog with 16 slot rows, each showing time, repeat mask, and snooze toggledialog/audio.rs- Ringtone upload dialog with file picker and signature selectiondialog/info.rs- Device information dialog (firmware, battery, MAC)dialog/settings.rs- Settings panel with sliders, spin buttons, and combo boxes for all device settings
Display Module
The display/ directory contains a custom seven-segment clock widget (seven_segment.rs) that renders the time using a DSEG7 font. The font files are bundled as assets:
assets/fonts/DSEG7Classic-Regular.ttfassets/fonts/DSEG7Classic-Light.ttf
Event Loop Integration
The GTK controller bridges async BLE operations with the GTK event loop using glib::MainContext::spawn_local. All spawned tasks accept a CancellationToken so they can be aborted when the associated view is closed:
#![allow(unused)]
fn main() {
fn watch_sensor_events(
device: ClockDevice,
sensor_card: SensorCard,
cancel_token: tokio_util::sync::CancellationToken,
) {
let mut receiver = device.subscribe();
spawn_local(async move {
loop {
tokio::select! {
biased;
_ = cancel_token.cancelled() => break,
event = receiver.recv() => {
match event {
Ok(ClockEvent::SensorUpdate { temperature, humidity }) => {
glib::idle_add_local_once(move || {
sensor_card.update(temperature, humidity);
});
}
Ok(ClockEvent::Disconnected) => break,
_ => {}
}
}
}
}
});
}
}
Features
Device Scanning
The scan dialog shows nearby devices with their advertisement data (temperature, humidity, battery). Clicking a device initiates connection and authentication. The scan callback also persists battery data from advertising to KnownDeviceStore and updates the scan_battery_cache.
Known Device Store
The controller maintains a KnownDeviceStore that persists:
- Known device MAC addresses (
known_devices.json) - Used to populate the device dropdown on startup and attempt automatic reconnection. - Battery levels (
battery_cache.json) - Map of MAC address to battery percentage, updated during scans and loaded on startup.
Both files are stored in the platform data directory (e.g., ~/.local/share/cgd1-rs/).
Sensor Monitoring
Real-time temperature and humidity are displayed via sensor cards that update from the ClockEvent stream. Battery level is shown with a percentage label and progress bar.
Battery Display
The controller does not read battery from GATT (the CGD1’s Battery Service characteristic returns an unreliable 99%). Instead, battery data is sourced from BLE advertising scans:
- On startup,
KnownDeviceStore::load_battery()populates the in-memoryscan_battery_cachefrombattery_cache.json. - During scans, the scan callback extracts battery from advertising TLV type
0x02, updatesscan_battery_cache, and persists viaKnownDeviceStore::save_battery(). - On connect, the connect handler reads the cached battery value and sends
ClockEvent::BatteryLevelto update the UI.
The device only advertises battery data when not connected and the button is held for 3 seconds. Periodic scans (every 60 seconds when disconnected) keep the cache fresh.
Bluetooth Icon States
The Bluetooth icon in the top-right corner reflects the connection state:
| State | CSS Class | Visual | When |
|---|---|---|---|
| Disconnected | bluetooth-off | Dim gray, 40% opacity | Initial state, after manual disconnect, after connect failure |
| Connecting / Reconnecting | bluetooth-blinking | Blinking animation (1s cycle) | During initial connect, during automatic reconnect |
| Connected | (none) | Full color, solid | After successful connect or reconnect |
The icon transitions through these states as follows:
- Initial →
bluetooth-off - Connect switch toggled on →
bluetooth-blinking - Connect succeeds → solid (remove
bluetooth-blinkingandbluetooth-off) - Connect fails →
bluetooth-off - Manual disconnect →
bluetooth-off ClockEvent::Disconnected(auto) →bluetooth-blinking(reconnect in progress)ClockEvent::Reconnected→ solid (removebluetooth-blinkingandbluetooth-off)
Alarm Editing
The alarm editor dialog shows all 16 slots in a list. Each row displays:
- Slot index
- Time (HH:MM)
- Repeat mask (as day names)
- Enabled toggle
- Snooze toggle
Editing a row sends the updated alarm to the device immediately.
Settings Panel
The settings dialog provides graphical controls for all device settings:
- Volume - Slider (1–5)
- Brightness - Slider (0–100, step 10)
- Night brightness - Slider (0–100, step 10)
- Night mode window - Hour/minute spin buttons for start and end
- Timezone - Spin button (-720 to +840 minutes)
- Time format - Combo box (12h / 24h)
- Temperature unit - Combo box (°C / °F)
- Language - Combo box (English, Chinese, German, Japanese)
- Ringtone - Combo box with built-in and custom ringtones
Changes are applied immediately to the device.
Ringtone Upload
The audio dialog provides a file picker for selecting a PCM audio file and a signature selector for choosing the target slot. The upload progress is shown with a progress bar.
CSS Styling
The application uses CSS classes for styling:
.sensor-card {
padding: 12px;
border-radius: 8px;
background-color: @theme_base_color;
}
.alarm-slot-row {
padding: 6px 12px;
}
.settings-panel scale {
margin: 6px 0;
}
Platform Support
The GTK 4 controller requires Linux with GTK 4 development libraries. It is not supported on macOS or Windows.
Internationalization (i18n)
The cgd1-rs-controller GTK 4 application supports UI translation via the Fluent localization system. Translation files (.ftl) are embedded into the binary at compile time using rust-embed, so no external files are needed at runtime.
Supported Languages
| Language | Code | Status |
|---|---|---|
| English | en | Fallback (all keys present) |
| German | de | Complete |
The application auto-detects the system language at startup via i18n_embed::DesktopLanguageRequester. If the requested language is not available, it falls back to English.
Selecting a Language
The language is determined by the system locale. You can override it via environment variables:
# English
LANG=en ./target/debug/cgd1-controller
# German
LANG=de_DE.UTF-8 ./target/debug/cgd1-controller
# Unsupported locale -> falls back to English
LANG=fr_FR.UTF-8 ./target/debug/cgd1-controller
Architecture
File Layout
cgd1-rs-controller/
├── i18n.toml # i18n-embed configuration
├── i18n/
│ ├── en/
│ │ └── cgd1-rs-controller.ftl # English (fallback)
│ └── de/
│ └── cgd1-rs-controller.ftl # German
└── src/
└── i18n.rs # Loader, macro, helpers
The .ftl filename must match the crate name (cgd1-rs-controller) — this is how the fl! macro resolves the domain at compile time.
Core Module (src/i18n.rs)
The i18n module provides:
fl!macro — Compile-time-validated translation lookup. Missing keys cause build failures.get()— Runtime lookup for dynamic keys (e.g. weekday names selected by index).get_str()/get_int()— Runtime lookup with a single string/integer argument.init()— Forces lazy initialization of the language loader. Called early inmain().
Initialization
In src/main.rs:
mod i18n;
fn main() {
// ... tracing setup ...
i18n::init();
let app = app::ClockControllerApp::new(cli.backend);
app.run();
}
Adding a New Language
- Create a new directory:
cgd1-rs-controller/i18n/{lang}/(e.g.fr/). - Copy
cgd1-rs-controller/i18n/en/cgd1-rs-controller.ftlto the new directory. - Translate all values to the target language.
- Rebuild — the new language is automatically embedded via
rust-embedand selected when the system locale matches.
No changes to i18n.toml or source code are needed. The rust-embed macro scans the i18n/ directory at compile time and includes all subdirectories.
Translation Key Conventions
- Use
kebab-casefor message IDs (e.g.status-no-device-connected). - Arguments use
{ $name }syntax in.ftlfiles. - Group keys by feature area with comment headers (e.g.
# Alarm editor,# Audio editor). - Shared keys (e.g.
status-no-device-connected,button-read) are defined once in the “Common status messages” section and reused across dialogs.
Using the fl! Macro (Contributors)
Static strings
For strings with known keys at compile time:
#![allow(unused)]
fn main() {
use crate::fl;
let label = Label::builder().label(&fl!("status-disconnected")).build();
}
Strings with arguments
#![allow(unused)]
fn main() {
status.set_label(&fl!("status-connected", addr = addr.to_string()));
}
Cast expressions must be parenthesized:
#![allow(unused)]
fn main() {
// Correct
status.set_label(&fl!("status-devices-found", count = (found.len() as i64)));
// Wrong — won't compile
status.set_label(&fl!("status-devices-found", count = found.len() as i64));
}
Dynamic lookups
For runtime-determined keys (e.g. weekday names looked up by index), use get():
#![allow(unused)]
fn main() {
use crate::i18n::get;
let key = match weekday {
Mon => "weekday-mon",
Tue => "weekday-tue",
// ...
};
let day_name = get(key);
}
What not to translate
- Log messages (
tracing::debug!,warn!,info!,error!) — developer-facing. - Error strings in
ClockError::Parse("device not found")— internal error handling. - Ringtone names from
RingtoneSignature::name()— device firmware identifiers. - Timezone labels in
common.rs— technical identifiers (UTC offsets). - Sensor units (
°C,%) — universal. - CLI help text — could be translated in a future phase using
clap’s i18n support.
WebSocket Server
The cgd1-rs-ws crate provides a WebSocket and REST server built on Axum. It exposes the full CGD1 device API over JSON for network integration (e.g., Home Assistant, web dashboards).
Installation
cargo install --path cgd1-rs-ws
Starting the Server
cgd1-ws --address 0.0.0.0 --port 3000
| Flag | Default | Description |
|---|---|---|
--address | 0.0.0.0 | Bind address |
--port | 3000 | Listen port |
-v, --verbose | 0 | Verbosity level (0–3) |
Architecture
flowchart TB
subgraph Server["cgd1-rs-ws"]
State["ServerState<br/>(transport, manager, token_store)"]
Listener["WebSocket Listener<br/>(axum)"]
Session["WsSession<br/>(per-connection)"]
Dispatch["dispatch_command<br/>+ subscribe_events"]
Commands["command/<br/>(free functions)"]
RestRoutes["REST Routes<br/>(GET /devices, etc.)"]
end
Client["WebSocket Client"] --> Listener
Listener --> Session
Session --> Dispatch
Dispatch --> Commands
Commands --> State
RestRoutes --> State
ServerState
ServerState owns the BLE transport, device manager, and token store. It is Clone (via Arc) and shared across all connections:
#![allow(unused)]
fn main() {
pub struct ServerState {
transport: Arc<BtleplugTransport>,
manager: Arc<ClockManager>,
token_store: Arc<FileTokenStore>,
}
}
WsSession
Each WebSocket connection creates a WsSession that processes incoming JSON requests and sends JSON responses. Per-message tasks are spawned with tokio::spawn for concurrent command handling.
WebSocket Protocol
Request Format
{
"id": 1,
"command": {
"type": "scan",
"duration_secs": 10
}
}
The id field is used to match responses to requests. The command field uses a tagged enum with snake_case serialization.
Response Format
{
"id": 1,
"result": { ... },
"error": null
}
Error responses:
{
"id": 1,
"result": null,
"error": "device AA:BB:CC:DD:EE:FF is not connected"
}
Commands
| Command | Parameters | Description |
|---|---|---|
scan | duration_secs | Scan for devices |
connect | address | Connect and authenticate |
disconnect | address | Disconnect from device |
sync_time | address | Synchronize device clock |
read_alarms | address | Read all alarm slots |
set_alarm | address, slot, time, repeat_mask, enabled, snooze | Set an alarm |
delete_alarm | address, slot | Delete an alarm |
read_settings | address | Read device settings |
write_settings | address, settings | Write device settings |
set_brightness | address, value | Set immediate brightness |
preview_ringtone | address, volume (optional) | Preview ringtone |
read_firmware | address | Read firmware version |
read_battery | address | Read battery level |
subscribe_events | address | Subscribe to push events |
All address fields use the MacAddress newtype (colon-separated hex). Duration uses ScanDuration (1–600 seconds). Slot indices use AlarmSlotIndex (0–15). Brightness uses Brightness (0–150, multiple of 10).
Event Subscription
After sending subscribe_events, the server pushes WsEvent messages to the client:
{
"event": "sensor_update",
"data": {
"temperature": 23.4,
"humidity": 45.6
}
}
Event types:
| Event | Payload | Description |
|---|---|---|
sensor_update | temperature, humidity | Real-time sensor data |
battery_level | level | Battery level change |
disconnected | (empty) | Device disconnected |
reconnected | (empty) | Device reconnected |
ack | command, status | Command ACK from device |
advertisement | (full advertisement data) | Passive advertisement received |
REST API
Read-only endpoints are available via REST:
| Method | Path | Description |
|---|---|---|
GET | /health | Server health check |
GET | /api/devices | List connected devices |
GET | /api/devices/{address}/sensors | Latest sensor data |
GET | /api/devices/{address}/battery | Battery level |
GET | /api/devices/{address}/firmware | Firmware version |
GET | /api/devices/{address}/alarms | All alarms |
GET | /api/devices/{address}/settings | Device settings |
Path parameters use {address} syntax (Axum 0.8+). The address is a MAC address in colon-separated hex format (e.g., AA:BB:CC:DD:EE:FF).
Example: REST Request
curl http://localhost:3000/api/devices/AA:BB:CC:DD:EE:FF/sensors
Response:
{
"temperature": 23.4,
"humidity": 45.6
}
Example: WebSocket Session
const ws = new WebSocket("ws://localhost:3000/ws");
ws.onopen = () => {
// Connect to a device
ws.send(JSON.stringify({
id: 1,
command: { type: "connect", address: "AA:BB:CC:DD:EE:FF" }
}));
// Subscribe to events
ws.send(JSON.stringify({
id: 2,
command: { type: "subscribe_events", address: "AA:BB:CC:DD:EE:FF" }
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.result !== null) {
console.log("Response:", msg);
} else if (msg.event) {
console.log("Event:", msg);
}
};
Error Handling
ServerError converts to HTTP status codes for REST endpoints:
| Error | Status Code |
|---|---|
NotConnected | 404 |
Json parse error | 400 |
Core (BLE/library errors) | 500 |
WebSocket errors are returned in the error field of the JSON response.
Platform Notes
Linux
BlueZ
Linux BLE access goes through BlueZ via D-Bus. The btleplug crate handles this internally, but BlueZ must be running and the user must have appropriate permissions.
Requirements:
bluezpackage installed and runningdbusrunning- User has permission to access the BLE adapter
Common issues:
Permission denied: Add the user to thebluetoothgroup or run with appropriate capabilitiesAdapter not found: Ensure Bluetooth is enabled in system settingsConnection refused: Verify the D-Bus system socket is running (systemctl status dbus)
GTK 4 Controller
The GTK 4 controller requires GTK 4 development libraries:
# Ubuntu/Debian
sudo apt-get install -y libgtk-4-dev
# Fedora
sudo dnf install -y gtk4-devel
# Arch
sudo pacman -S gtk4
macOS
CoreBluetooth
macOS uses CoreBluetooth for BLE access. No extra system packages are needed for the core library or CLI.
Limitations:
- The GTK 4 controller is not supported on macOS
- macOS requires Bluetooth to be enabled in System Settings
- The first BLE scan may prompt for Bluetooth permission
Token Storage
Tokens are stored in ~/Library/Application Support/cgd1-rs/tokens/.
Windows
Windows uses the Windows Bluetooth API. The core library and CLI should work, but this is less tested than Linux.
Requirements:
- Windows 10 or later
- Bluetooth adapter enabled
BLE Range and Stability
- Range: The CGD1 uses Bluetooth 5.0 with a typical indoor range of 10–15 meters
- Interference: 2.4 GHz Wi-Fi can cause BLE interference. If connections are unstable, try moving closer or switching Wi-Fi channels
- Multiple connections: The library supports multiple simultaneous device connections, but BLE bandwidth is shared
Firmware Compatibility
Known firmwares:
1.0.1_00461.0.1_00631.0.1_00671.0.1_01261.0.1_01301.0.1_0132
If you encounter a new firmware version, please report it on GitHub Issues.
Alarm Slot Count
The clOwOck specification documents 16 alarm slots. The ov1d1u Home Assistant integration uses 19 slots. This discrepancy may be firmware-dependent. The library defaults to 16 slots (indices 0–15).
Battery Notes
- The CGD1 uses 2 × AA batteries with a standby time of over 1 year
- Battery level is available passively via advertisements and actively via the GATT battery service
- The passive advertisement battery byte has bit 7 masked (
& 0x7F)
Audio Upload Notes
- Audio uploads require a stable connection; parallel BLE operations can abort the transfer
- The MTU exchange is critical for audio uploads - without a sufficient MTU, packets would need fragmentation
- Maximum audio duration is approximately 12 seconds (~98 KB at 8 kHz, 8-bit mono)
Examples
Cron Time Synchronization
Sync the device clock daily via cron:
# Sync CGD1 clock every day at 3:00 AM
0 3 * * * /home/user/.cargo/bin/cgd1 sync-time AA:BB:CC:DD:EE:FF
Home Assistant Integration via WebSocket
Start the WebSocket server:
cgd1-ws --port 3000 &
Home Assistant custom component (Python):
import asyncio
import json
import websockets
async def monitor_device():
uri = "ws://localhost:3000/ws"
async with websockets.connect(uri) as ws:
# Connect to device
await ws.send(json.dumps({
"id": 1,
"command": {"type": "connect", "address": "AA:BB:CC:DD:EE:FF"}
}))
print(await ws.recv())
# Subscribe to sensor events
await ws.send(json.dumps({
"id": 2,
"command": {"type": "subscribe_events", "address": "AA:BB:CC:DD:EE:FF"}
}))
print(await ws.recv())
# Listen for events
async for message in ws:
data = json.loads(message)
if data.get("event") == "sensor_update":
temp = data["data"]["temperature"]
humidity = data["data"]["humidity"]
print(f"Temperature: {temp} C, Humidity: {humidity} %")
asyncio.run(monitor_device())
Alarm Scheduling Script
Set up a weekday alarm using a shell script:
#!/bin/bash
DEVICE="AA:BB:CC:DD:EE:FF"
# Set weekday alarm at 07:00 in slot 0
cgd1 alarm-set "$DEVICE" 0 07:00 --repeat 3e
# Set weekend alarm at 08:30 in slot 1
cgd1 alarm-set "$DEVICE" 1 08:30 --repeat 41
# Verify
cgd1 alarm-list "$DEVICE"
Batch Settings Configuration
Apply settings to multiple devices:
#!/bin/bash
DEVICES=(
"AA:BB:CC:DD:EE:FF"
"11:22:33:44:55:66"
)
for mac in "${DEVICES[@]}"; do
echo "Configuring $mac..."
cgd1 sync-time "$mac"
cgd1 settings-write "$mac" \
--volume 3 \
--brightness 80 \
--night-brightness 20 \
--timezone 60 \
--time-format 24 \
--temp-unit C \
--language en
done
Library Usage: Full Device Setup
use cgd1_rs::{
AlarmSlotIndex, Brightness, ClockManager, ClockTime, DayMask,
BtleplugTransport, FileTokenStore, Language, MacAddress,
TemperatureUnit, TimeFormat, TokenStore, Volume,
};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let transport = Arc::new(BtleplugTransport::new().await?);
let manager = ClockManager::new(transport.clone());
let store = Arc::new(FileTokenStore::default_directory());
let mac: MacAddress = "AA:BB:CC:DD:EE:FF".parse()?;
let device = manager.connect(&mac).await?;
// Authenticate
let token_result = store.load_or_generate(&mac);
device.set_token_store(store.clone() as Arc<dyn TokenStore>);
device.authenticate(&token_result).await?;
// Sync time
device.sync_time_now().await?;
if token_result.is_new() {
store.save(&mac, &token_result)?;
}
// Set weekday alarm
device.set_alarm(
AlarmSlotIndex::new(0)?,
ClockTime::new(7, 0)?,
DayMask::WEEKDAYS,
true,
true,
).await?;
// Configure settings
let mut settings = device.read_settings().await?;
settings.volume = Volume::new(3)?;
settings.brightness = Brightness::new(80)?;
settings.time_format = TimeFormat::TwentyFourHour;
settings.temperature_unit = TemperatureUnit::Celsius;
settings.language = Language::English;
device.write_settings(&settings).await?;
println!("Device configured successfully!");
// Monitor sensors
let mut receiver = device.subscribe();
while let Ok(event) = receiver.recv().await {
if let cgd1_rs::ClockEvent::SensorUpdate { temperature, humidity } = event {
println!("Temperature: {:.1} C Humidity: {:.1} %",
temperature.value(), humidity.value());
}
}
Ok(())
}
WebSocket Server with systemd
Create a systemd service for the WebSocket server:
# /etc/systemd/system/cgd1-ws.service
[Unit]
Description=CGD1 WebSocket Server
After=bluetooth.target
[Service]
Type=simple
User=pi
ExecStart=/home/pi/.cargo/bin/cgd1-ws --port 3000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable cgd1-ws
sudo systemctl start cgd1-ws
Troubleshooting
BLE Connection Issues
Device not found during scan
Symptom: cgd1 scan returns no devices.
Solutions:
- Ensure the CGD1 is powered on (batteries inserted)
- Move closer to the device (within 5 meters)
- Verify Bluetooth is enabled on the host:
bluetoothctl power on - Check that the BLE adapter is available:
hciconfig - Stop other BLE applications that may be holding the adapter (e.g., other scanning tools)
Connection fails
Symptom: Error: Transport(NotConnected) or Error: Transport(Timeout).
Solutions:
- Ensure the device is not currently connected to another host (the CGD1 supports only one active BLE connection)
- Restart Bluetooth:
sudo systemctl restart bluetooth - Remove any existing pairing from the OS Bluetooth manager:
bluetoothctl remove AA:BB:CC:DD:EE:FF - Try connecting again after a few seconds
Connection drops unexpectedly
Symptom: ClockEvent::Disconnected events or Error: Transport(Timeout) during operations.
Solutions:
- Check battery level - low batteries can cause disconnections
- Reduce distance between host and device
- Avoid 2.4 GHz Wi-Fi interference (switch to 5 GHz or change Wi-Fi channel)
- The library will attempt automatic reconnection with exponential backoff
Authentication Issues
Authentication fails on first connection
Symptom: Error: AuthFailed with is_new_token: true.
Solutions:
- This should not happen on a fresh device. Ensure you are connecting to the correct MAC address
- Try generating a new token by deleting the token file:
rm ~/.local/share/cgd1-rs/tokens/AA_BB_CC_DD_EE_FF.bin - Run
sync-timeagain to generate and store a new token
Authentication fails after previously working
Symptom: Error: AuthFailed with is_new_token: false.
Cause: The device was likely paired with a different app (e.g., the official Qingping app), which overwrites the authentication token.
Solutions:
- Delete the stored token:
rm ~/.local/share/cgd1-rs/tokens/AA_BB_CC_DD_EE_FF.bin - Reconnect with
sync-timeto generate a new token - Note: The official app and
cgd1-rscannot share the same token. Using one will invalidate the other’s token
sync-time times out (token not accepted)
Symptom: Authentication ACKs succeed (04 ff 01 00 .., 04 ff 02 00 ..), but sync-time receives no ACK and times out after 10 seconds.
Cause: The device sends Auth ACKs even with a bad token. The real token acceptance is only proven by a privileged command like sync-time. If sync-time times out, the device has a different token stored from a previous pairing (another app, a prior run with a different random token, etc.). The device requires an explicit factory reset before it accepts a new token.
Solutions:
- Perform a factory reset on the CGD1 device (see Factory Reset below)
- Delete any stored token file for this device:
rm ~/.local/share/cgd1-rs/AA_BB_CC_DD_EE_FF - Reconnect - a new token will be generated and, after
sync-timesucceeds, persisted automatically
sync-time succeeds but other commands fail
Symptom: sync-time works, but alarm-set or settings-write returns errors.
Solutions:
- Verify the device is still connected (
cgd1 battery <mac>) - Check that the token was persisted (look for the token file in
~/.local/share/cgd1-rs/tokens/) - Try disconnecting and reconnecting
Audio Upload Issues
Upload fails with MTU error
Symptom: Error: MtuTooSmall { mtu: ... }.
Solutions:
- The device or host does not support a sufficient MTU. This is a hardware limitation
- Try restarting Bluetooth and reconnecting
- Some BLE adapters negotiate a lower MTU on first connection; disconnect and reconnect
Upload aborts mid-transfer
Symptom: Upload starts but fails partway through.
Solutions:
- Ensure no other BLE operations are running concurrently (alarm reads, settings reads, RSSI polling)
- Keep the device close to the host during the entire transfer
- Verify the audio file is valid 8-bit unsigned PCM at 8 kHz mono
- Check the file size is under 98 KB
Audio plays incorrectly after upload
Solutions:
- Verify the source audio is 8-bit unsigned PCM (not signed, not 16-bit)
- Verify the sample rate is exactly 8000 Hz
- Verify the audio is mono
- Try alternating the signature slot (
CustomSlotA→CustomSlotB)
GTK Controller Issues
Application fails to start
Symptom: error: failed to run command: cgd1-rs-controller or GTK warnings.
Solutions:
- Verify GTK 4 is installed:
pkg-config --modversion gtk4 - Check for missing CSS or font resources
Sensor cards not updating
Solutions:
- Verify the device is connected (check the sidebar)
- Try disconnecting and reconnecting
- Check the application logs with verbosity enabled
Virtual Backend
Using the virtual backend for testing
The --backend virtual flag uses an in-memory device simulation:
cgd1 --backend virtual scan
cgd1 --backend virtual sync-time AA:BB:CC:DD:EE:FF
cgd1 --backend virtual alarm-list AA:BB:CC:DD:EE:FF
This works without any BLE hardware and is useful for testing CLI behavior, scripts, and the WebSocket server.
Factory Reset
A factory reset clears the stored authentication token on the CGD1, allowing it to accept a new token. This is required when:
sync-timetimes out after successful Auth ACKs (token mismatch)- The device was previously paired with the official Qingping app or another host
- A previous run generated a random token that was never persisted but the device stored it
Step-by-Step Instructions
- Batteriefach öffnen: Öffnen Sie die Abdeckung auf der Rückseite des Geräts und entnehmen Sie die Batterien.
- Gerät gedrückt halten: Drücken und halten Sie das gesamte Gehäuse von oben nach unten (die “Snooze/Licht”-Taste des Weckers drückt sich dadurch am Boden ein).
- Batterien wiedereinsetzen: Setzen Sie die Batterien ein, während Sie das Gerät weiterhin kontinuierlich gedrückt halten.
- Halten für 12 Sekunden: Halten Sie das Gerät für mindestens 12 Sekunden fixiert nach unten gedrückt.
- Ergebnis prüfen: Sobald auf dem Display alle Zahlen als “8” aufleuchten, ist der Werksreset abgeschlossen. Lassen Sie das Gerät nun los.
After the Reset
- Delete any stale token files for this device:
rm ~/.local/share/cgd1-rs/AA_BB_CC_DD_EE_FF - Reconnect with
cgd1-rs- a new random token will be generated - After
sync-timesucceeds, the token is automatically persisted for future connections
Warning: Factory reset clears all device settings (alarms, brightness, volume, etc.) in addition to the auth token.
Reporting Issues
If you encounter a bug or have a feature request, please open an issue on GitHub.
Include:
- The command or code that triggered the issue
- The full error output (use
-vvvfor maximum verbosity) - Your OS and Bluetooth adapter model
- The device firmware version (
cgd1 firmware <mac>)