Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

PropertyValue
ModelCGD1
ConnectivityBluetooth 5.0 (BLE)
SensorsTemperature, humidity (Sensirion)
Power2 × AA batteries, > 1 year standby
DisplayLCD with adjustable backlight
Alarm slotsUp to 16
Custom ringtones2 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

CrateDescription
cgd1-rsCore library: BLE transport, auth, commands, events, device handle
cgd1-rs-cliCommand-line tool with 14 subcommands and interactive REPL
cgd1-rs-controllerGTK 4 desktop application with sensor display, alarm editor, and settings panel
cgd1-rs-wsWebSocket 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:

CrateMSRV
cgd1-rs1.88
cgd1-rs-cli1.89
cgd1-rs-ws1.88
cgd1-rs-controller1.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::Sender for 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 - JoinHandle stored 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 via KnownDeviceStore. 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:

  1. prepare_ack(command) - Registers a oneshot::Sender in the pending map
  2. transport.write_frame(command, payload) - Sends the command
  3. wait_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:

  1. Transport cleanup - transport.disconnect() to clear stale connection state
  2. BLE Reconnect via transport.connect(address)
  3. GATT Re-subscription for Auth Notify, Data Notify, and Sensor Notify
  4. Re-authentication using the stored token
  5. Flag reset - is_authenticated set to true

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’s scan_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:

TypeValidationUsed By
MacAddress6-byte MAC, colon-separated hexAll commands
ScanDuration1–600 secondsscan
AlarmSlotIndex0–15alarm-set, alarm-delete
ClockTimeHH:MM (0–23, 0–59)alarm-set
DayMasku8 bitmaskalarm-set
Brightness0–150, multiple of 10brightness, settings-write
Volume1–5settings-write, ringtone-preview
Timezone-720 to +840 minutessettings-write
RingtoneSignature4 bytes or named slotringtone-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

NameUUIDDirection
Auth Write00000001-0000-1000-8000-00805f9b34fbHost → Device
Auth Notify00000002-0000-1000-8000-00805f9b34fbDevice → Host
Data Write0000000b-0000-1000-8000-00805f9b34fbHost → Device
Data Notify0000000c-0000-1000-8000-00805f9b34fbDevice → Host
Sensor Notify00000100-0000-1000-8000-00805f9b34fbDevice → Host

Standard Services

ServiceUUIDCharacteristicUUIDFormat
Battery0x180fBattery Level0x2a191 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

LengthCommandOperationCharacteristic
0x110x01Auth InitAuth Write
0x110x02Auth ConfirmAuth Write
0x050x09Time SyncAuth Write
0x010x0dRead FirmwareAuth Write
0x010x02Read SettingsData Write
0x130x01Set SettingsData Write
0x020x03Set BrightnessData Write
0x010x04Preview Ringtone (current vol)Data Write
0x020x04Preview Ringtone (specific vol)Data Write
0x010x06Read AlarmsData Write
0x070x05Set/Delete AlarmData Write
0x080x10Audio InitData Write
0x810x08Audio Data PacketData 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 FDCD service-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());
    }
}
}

The AdvertisementData struct is parsed from the raw service-data payload:

FieldTypeScaling
MAC6 bytes (reversed)-
TemperatureInt16 BE/ 10 (°C)
HumidityUInt16 BE/ 10 (%)
BatteryUInt8& 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:

  1. BLE connect - transport.connect(address)
  2. Subscribe to Auth Notify, Data Notify, and Sensor Notify characteristics
  3. Spawn notification task - Background task for processing BLE notifications
  4. Authenticate - Two-step token handshake (Auth Init + Auth Confirm)
  5. Sync timezone - Read device settings, compute local UTC offset, write correct timezone
  6. 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:

  1. Disconnect detected - The notification stream ends or a CentralEvent::DeviceDisconnected is received
  2. Transport cleanup - transport.disconnect() clears the connection state to avoid AlreadyConnected errors on retry
  3. Reconnect attempts - Up to 10 attempts with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s capped)
  4. BLE connect + subscribe - reconnect_and_restore reconnects and re-subscribes to all notify characteristics
  5. Re-authentication - A separate task re-authenticates using the stored token (spawned concurrently so the notification loop can process ACKs)
  6. State recovery - On success, ClockEvent::Reconnected is 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

  1. Subscribe to Auth Notify (00000002-...)
  2. Auth Init: Send 11 01 [Token 16B] to Auth Write
  3. Wait for ACK: 04 ff 01 00 [Payload] (status 00 = success)
  4. Auth Confirm: Send 11 02 [Token 16B] to Auth Write
  5. 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:

FieldDescription
reasonHuman-readable failure reason
is_new_tokenWhether the token was newly generated (not yet paired)
token_pathFilesystem 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]
FieldBytesDescription
Enabled10x01 = on, 0x00 = off
HH1Hour (0–23)
MM1Minute (0–59)
Days1Day bitmask (see below)
Snooze10x01 = on, 0x00 = off

An empty/unused slot has all bytes set to 0xFF: FF FF FF FF FF.

DayMask Bitmask

BitValueDay
00x01Monday
10x02Tuesday
20x04Wednesday
30x08Thursday
40x10Friday
50x20Saturday
60x40Sunday
-0x00Once (no repeat)

Common patterns:

NameValueDays
Every day0x7FMon–Sun
Weekdays0x3EMon–Fri
Weekends0x41Sat–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
ArgumentDescription
addressDevice MAC address
slotSlot index 0–15
timeAlarm time in HH:MM format
--repeatDay mask as hex (default: 7f = every day)
--no-snoozeDisable 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:

ConstantValueDescription
DayMask::ONCE0x00No repeat (one-shot)
DayMask::EVERY_DAY0x7FMonday through Sunday
DayMask::WEEKDAYS0x3EMonday through Friday
DayMask::WEEKENDS0x41Saturday 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]
OffsetFieldBytesDescription
0Header10x13 (length byte)
1Command10x01 (set) or 0x02 (read response)
2Volume1Sound volume (1–5)
3Hdr11Fixed 0x58
4Hdr21Fixed 0x02
5Flags1Mode bitfield (see below)
6Timezone1Offset in 6-minute units (minutes / 6)
7Duration1Screen light duration in seconds
8Brightness1Packed brightness (see below)
9NightStartH1Night mode start hour (0–23)
10NightStartM1Night mode start minute (0–59)
11NightEndH1Night mode end hour (0–23)
12NightEndM1Night mode end minute (0–59)
13TzSign10x01 = positive, 0x00 = negative
14NightEn10x01 = enabled, 0x00 = disabled
15Reserved1Set to 0xFF
16–19Signature4Ringtone signature (0xFFFFFFFF when unused)

Flags Bitfield (Byte 5)

BitMaskField01
00x01LanguageChineseEnglish
10x02Time Format24-hour12-hour
20x04Temperature UnitCelsiusFahrenheit
40x10AlarmsEnabledDisabled

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
FlagValuesDescription
--volume1–5Sound volume
--brightness0–150 (multiple of 10)Daytime brightness
--night-brightness0–150 (multiple of 10)Nighttime brightness
--timezone-720 to +840 (minutes)Timezone offset
--time-format12 or 24Time display format
--temp-unitC or FTemperature unit
--languageen, zh, de, jaDisplay 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

ModeDataSourceRequires Connection
PassiveTemperature, humidity, batteryBLE advertisements (FDCD)No
ConnectedTemperature, humidity (real-time)Sensor Notify (00000100-...)Yes
ConnectedBattery (cached from advertising)KnownDeviceStore battery cacheNo (cached)
On-demandBattery (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. The read_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]
FieldTypeScaling
TemperatureInt16 LE/ 10.0 (°C)
HumidityUInt16 LE/ 10.0 (%RH)
BatteryUInt8& 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.

FieldTypeScaling
TemperatureSigned Int16 LE/ 100.0 (°C)
HumidityUnsigned 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-memory scan_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

PropertyValue
Format8-bit unsigned PCM
Sample rate8000 Hz
ChannelsMono
Max size~98 KB (~12 seconds)
PaddingMultiple 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.

SignatureNameSourceCopyright holderLicensePCM length
fdc366a5BeepAlarm clock.ogg2013, Canonical Ltd.CC-BY-SA-3.095967
0961bb77Digital RingtoneMallet.ogg2013, Canonical Ltd.CC-BY-SA-3.018155
ba2c2c8cDigital Ringtone 2Sintonia.ogg2018, Mauricio DuarteCC-BY-4.018462
ea2d4c02CuckooCounterpoint.ogg2013, Canonical Ltd.CC-BY-SA-3.076522
791bacb3Telephone RingtoneCall me.ogg2018, AnonymousCC0-1.095967
1d019fd6Exotic GuitarLatin.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
6e70b659Lively PianoUBports.ogg2018, Mauricio DuarteCC-BY-4.096000
8f004886Story PianoMelody piano.ogg2013, Canonical Ltd.CC-BY-SA-3.086043
26522519Forest PianoMangore.ogg2018, Mauricio DuarteCC-BY-4.069819
4d6f6e6bMonkey Islandmonkey-island-8bitKalle Jonsson (Dubmood)CC-BY-NC-SA-4.080000
416c5379Alarm SynthAlarm synth.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
41724d62Array MbiraArray mbira.ogg2013, Canonical Ltd.CC-BY-SA-3.079033
426c6973BlissBliss.ogg2013, Canonical Ltd.CC-BY-SA-3.047181
43656c73CelestialCelestial.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
456e7472EntropyEntropy.ogg2018, Mauricio DuarteCC-BY-4.096000
476c4d61Glass MarimbaGlass marimba.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
48616c6fHalo PentatonicHalo Pentatonic.ogg2013, Canonical Ltd.CC-BY-SA-3.087819
4861726dHarmonicsHarmonics.ogg2013, Canonical Ltd.CC-BY-SA-3.064230
48617270Harp ArpHarp arp.ogg2013, Canonical Ltd.CC-BY-SA-3.050077
4b6f746fKoto ChordsKoto chords.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
53616b65Sakenointisakenointi.ogg2018, TMetsoCC-BY-4.078454
53616d73Sam’s SongSam’s Song.ogg2013, Sam HulickCC-BY-SA-3.038147
536f756cSoulSoul.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
53706172SparkleSparkle.ogg2013, Canonical Ltd.CC-BY-SA-3.071332
53757072SupremeSupreme.ogg2013, Canonical Ltd.CC-BY-SA-3.091793
53757275Suru ArpeggioSuru arpeggio.ogg2013, Canonical Ltd.CC-BY-SA-3.096000
54696d65Time Not LostTime not Lost.ogg2018, Mauricio DuarteCC-BY-4.095944
576f6f64Wooden DriveWooden Drive.ogg2018, Amber ForestCC-BY-3.056816
958f8a83ElysiumELYSIUM.MODJesterCC-BY-NC-SA-4.096000

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:

SignatureNameConstant
deaddeadCustom Slot ARingtoneSignature::CustomSlotA
beefbeefCustom Slot BRingtoneSignature::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

  1. 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]).

  2. 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.

  3. Read-back: When reading settings from the device, if the device reports a Custom signature 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::Custom with a derived hash signature, not the fixed CustomSlotA/CustomSlotB slots. 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

  1. Decode/resample the source file to 8-bit unsigned PCM, 8000 Hz, mono
  2. Pad to a multiple of 512 bytes: first padding byte is 00 (end-of-audio marker), remaining are FF
  3. 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
ArgumentDescription
addressDevice MAC address
filePath to 8-bit PCM audio file (8 kHz, mono)
--signatureRingtone 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
FlagDefaultRangeDescription
-d, --duration101–600Scan 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
ArgumentDescription
addressDevice MAC address
slotSlot index 0–15
timeAlarm time in HH:MM format
-r, --repeatDay mask as hex (default: 7f = every day)
--no-snoozeDisable 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
FlagValuesDescription
--volume1–5Sound volume
--brightness0–150 (multiple of 10)Daytime brightness
--night-brightness0–150 (multiple of 10)Nighttime brightness
--timezone-720 to +840Timezone offset in minutes
--time-format12 or 24Time display format
--temp-unitC or FTemperature unit
--languageen, zh, de, jaDisplay 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
ArgumentDescription
addressDevice MAC address
filePath to 8-bit PCM audio file (8 kHz, mono)
-s, --signatureRingtone 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
FlagDefaultDescription
-d, --duration0Duration 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:

LevelOutput
(none)Errors only
-vWarnings + errors
-vvInfo + warnings + errors
-vvvDebug (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 toggle
  • dialog/audio.rs - Ringtone upload dialog with file picker and signature selection
  • dialog/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.ttf
  • assets/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:

  1. On startup, KnownDeviceStore::load_battery() populates the in-memory scan_battery_cache from battery_cache.json.
  2. During scans, the scan callback extracts battery from advertising TLV type 0x02, updates scan_battery_cache, and persists via KnownDeviceStore::save_battery().
  3. On connect, the connect handler reads the cached battery value and sends ClockEvent::BatteryLevel to 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:

StateCSS ClassVisualWhen
Disconnectedbluetooth-offDim gray, 40% opacityInitial state, after manual disconnect, after connect failure
Connecting / Reconnectingbluetooth-blinkingBlinking animation (1s cycle)During initial connect, during automatic reconnect
Connected(none)Full color, solidAfter 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-blinking and bluetooth-off)
  • Connect fails → bluetooth-off
  • Manual disconnect → bluetooth-off
  • ClockEvent::Disconnected (auto) → bluetooth-blinking (reconnect in progress)
  • ClockEvent::Reconnected → solid (remove bluetooth-blinking and bluetooth-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

LanguageCodeStatus
EnglishenFallback (all keys present)
GermandeComplete

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 in main().

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

  1. Create a new directory: cgd1-rs-controller/i18n/{lang}/ (e.g. fr/).
  2. Copy cgd1-rs-controller/i18n/en/cgd1-rs-controller.ftl to the new directory.
  3. Translate all values to the target language.
  4. Rebuild — the new language is automatically embedded via rust-embed and 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-case for message IDs (e.g. status-no-device-connected).
  • Arguments use { $name } syntax in .ftl files.
  • 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
FlagDefaultDescription
--address0.0.0.0Bind address
--port3000Listen port
-v, --verbose0Verbosity 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

CommandParametersDescription
scanduration_secsScan for devices
connectaddressConnect and authenticate
disconnectaddressDisconnect from device
sync_timeaddressSynchronize device clock
read_alarmsaddressRead all alarm slots
set_alarmaddress, slot, time, repeat_mask, enabled, snoozeSet an alarm
delete_alarmaddress, slotDelete an alarm
read_settingsaddressRead device settings
write_settingsaddress, settingsWrite device settings
set_brightnessaddress, valueSet immediate brightness
preview_ringtoneaddress, volume (optional)Preview ringtone
read_firmwareaddressRead firmware version
read_batteryaddressRead battery level
subscribe_eventsaddressSubscribe 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:

EventPayloadDescription
sensor_updatetemperature, humidityReal-time sensor data
battery_levellevelBattery level change
disconnected(empty)Device disconnected
reconnected(empty)Device reconnected
ackcommand, statusCommand ACK from device
advertisement(full advertisement data)Passive advertisement received

REST API

Read-only endpoints are available via REST:

MethodPathDescription
GET/healthServer health check
GET/api/devicesList connected devices
GET/api/devices/{address}/sensorsLatest sensor data
GET/api/devices/{address}/batteryBattery level
GET/api/devices/{address}/firmwareFirmware version
GET/api/devices/{address}/alarmsAll alarms
GET/api/devices/{address}/settingsDevice 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:

ErrorStatus Code
NotConnected404
Json parse error400
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:

  • bluez package installed and running
  • dbus running
  • User has permission to access the BLE adapter

Common issues:

  • Permission denied: Add the user to the bluetooth group or run with appropriate capabilities
  • Adapter not found: Ensure Bluetooth is enabled in system settings
  • Connection 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_0046
  • 1.0.1_0063
  • 1.0.1_0067
  • 1.0.1_0126
  • 1.0.1_0130
  • 1.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:

  1. Ensure the CGD1 is powered on (batteries inserted)
  2. Move closer to the device (within 5 meters)
  3. Verify Bluetooth is enabled on the host:
    bluetoothctl power on
    
  4. Check that the BLE adapter is available:
    hciconfig
    
  5. 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:

  1. Ensure the device is not currently connected to another host (the CGD1 supports only one active BLE connection)
  2. Restart Bluetooth:
    sudo systemctl restart bluetooth
    
  3. Remove any existing pairing from the OS Bluetooth manager:
    bluetoothctl remove AA:BB:CC:DD:EE:FF
    
  4. Try connecting again after a few seconds

Connection drops unexpectedly

Symptom: ClockEvent::Disconnected events or Error: Transport(Timeout) during operations.

Solutions:

  1. Check battery level - low batteries can cause disconnections
  2. Reduce distance between host and device
  3. Avoid 2.4 GHz Wi-Fi interference (switch to 5 GHz or change Wi-Fi channel)
  4. 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:

  1. This should not happen on a fresh device. Ensure you are connecting to the correct MAC address
  2. Try generating a new token by deleting the token file:
    rm ~/.local/share/cgd1-rs/tokens/AA_BB_CC_DD_EE_FF.bin
    
  3. Run sync-time again 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:

  1. Delete the stored token:
    rm ~/.local/share/cgd1-rs/tokens/AA_BB_CC_DD_EE_FF.bin
    
  2. Reconnect with sync-time to generate a new token
  3. Note: The official app and cgd1-rs cannot 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:

  1. Perform a factory reset on the CGD1 device (see Factory Reset below)
  2. Delete any stored token file for this device:
    rm ~/.local/share/cgd1-rs/AA_BB_CC_DD_EE_FF
    
  3. Reconnect - a new token will be generated and, after sync-time succeeds, persisted automatically

sync-time succeeds but other commands fail

Symptom: sync-time works, but alarm-set or settings-write returns errors.

Solutions:

  1. Verify the device is still connected (cgd1 battery <mac>)
  2. Check that the token was persisted (look for the token file in ~/.local/share/cgd1-rs/tokens/)
  3. Try disconnecting and reconnecting

Audio Upload Issues

Upload fails with MTU error

Symptom: Error: MtuTooSmall { mtu: ... }.

Solutions:

  1. The device or host does not support a sufficient MTU. This is a hardware limitation
  2. Try restarting Bluetooth and reconnecting
  3. 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:

  1. Ensure no other BLE operations are running concurrently (alarm reads, settings reads, RSSI polling)
  2. Keep the device close to the host during the entire transfer
  3. Verify the audio file is valid 8-bit unsigned PCM at 8 kHz mono
  4. Check the file size is under 98 KB

Audio plays incorrectly after upload

Solutions:

  1. Verify the source audio is 8-bit unsigned PCM (not signed, not 16-bit)
  2. Verify the sample rate is exactly 8000 Hz
  3. Verify the audio is mono
  4. 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:

  1. Verify GTK 4 is installed:
    pkg-config --modversion gtk4
    
  2. Check for missing CSS or font resources

Sensor cards not updating

Solutions:

  1. Verify the device is connected (check the sidebar)
  2. Try disconnecting and reconnecting
  3. 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-time times 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

  1. Batteriefach öffnen: Öffnen Sie die Abdeckung auf der Rückseite des Geräts und entnehmen Sie die Batterien.
  2. 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).
  3. Batterien wiedereinsetzen: Setzen Sie die Batterien ein, während Sie das Gerät weiterhin kontinuierlich gedrückt halten.
  4. Halten für 12 Sekunden: Halten Sie das Gerät für mindestens 12 Sekunden fixiert nach unten gedrückt.
  5. 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

  1. Delete any stale token files for this device:
    rm ~/.local/share/cgd1-rs/AA_BB_CC_DD_EE_FF
    
  2. Reconnect with cgd1-rs - a new random token will be generated
  3. After sync-time succeeds, 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 -vvv for maximum verbosity)
  • Your OS and Bluetooth adapter model
  • The device firmware version (cgd1 firmware <mac>)