qingping-cgd1 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qingping_cgd1-0.1.0/.github/workflows/ci.yml +82 -0
- qingping_cgd1-0.1.0/.github/workflows/release.yml +43 -0
- qingping_cgd1-0.1.0/.gitignore +26 -0
- qingping_cgd1-0.1.0/LICENSE +21 -0
- qingping_cgd1-0.1.0/PKG-INFO +126 -0
- qingping_cgd1-0.1.0/README.md +111 -0
- qingping_cgd1-0.1.0/lefthook.yml +21 -0
- qingping_cgd1-0.1.0/mise.toml +11 -0
- qingping_cgd1-0.1.0/pyproject.toml +102 -0
- qingping_cgd1-0.1.0/qingping_cgd1/__init__.py +56 -0
- qingping_cgd1-0.1.0/qingping_cgd1/client.py +341 -0
- qingping_cgd1-0.1.0/qingping_cgd1/codec.py +265 -0
- qingping_cgd1-0.1.0/qingping_cgd1/const.py +44 -0
- qingping_cgd1-0.1.0/qingping_cgd1/exceptions.py +23 -0
- qingping_cgd1-0.1.0/qingping_cgd1/models.py +93 -0
- qingping_cgd1-0.1.0/qingping_cgd1/py.typed +0 -0
- qingping_cgd1-0.1.0/tests/__init__.py +0 -0
- qingping_cgd1-0.1.0/tests/conftest.py +24 -0
- qingping_cgd1-0.1.0/tests/fake_ble.py +133 -0
- qingping_cgd1-0.1.0/tests/test_client_actions.py +100 -0
- qingping_cgd1-0.1.0/tests/test_client_commands.py +63 -0
- qingping_cgd1-0.1.0/tests/test_client_connect.py +90 -0
- qingping_cgd1-0.1.0/tests/test_client_idle.py +74 -0
- qingping_cgd1-0.1.0/tests/test_client_resilience.py +66 -0
- qingping_cgd1-0.1.0/tests/test_codec_advertisement.py +47 -0
- qingping_cgd1-0.1.0/tests/test_codec_alarm.py +62 -0
- qingping_cgd1-0.1.0/tests/test_codec_next_alarm.py +77 -0
- qingping_cgd1-0.1.0/tests/test_codec_sensor.py +46 -0
- qingping_cgd1-0.1.0/tests/test_codec_settings.py +100 -0
- qingping_cgd1-0.1.0/tests/test_codec_time.py +17 -0
- qingping_cgd1-0.1.0/tests/test_const.py +39 -0
- qingping_cgd1-0.1.0/tests/test_exceptions.py +31 -0
- qingping_cgd1-0.1.0/tests/test_models.py +73 -0
- qingping_cgd1-0.1.0/tests/test_public_api.py +44 -0
- qingping_cgd1-0.1.0/tests/test_readme.py +21 -0
- qingping_cgd1-0.1.0/tests/test_smoke.py +10 -0
- qingping_cgd1-0.1.0/uv.lock +650 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ci-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
lint:
|
|
18
|
+
name: Ruff
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
22
|
+
with:
|
|
23
|
+
persist-credentials: false
|
|
24
|
+
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.14"
|
|
27
|
+
enable-cache: true
|
|
28
|
+
- run: uv sync --group dev
|
|
29
|
+
- run: uv run ruff check --output-format=github .
|
|
30
|
+
- run: uv run ruff format --check .
|
|
31
|
+
|
|
32
|
+
typecheck:
|
|
33
|
+
name: mypy --strict
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
37
|
+
with:
|
|
38
|
+
persist-credentials: false
|
|
39
|
+
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
40
|
+
with:
|
|
41
|
+
python-version: "3.14"
|
|
42
|
+
enable-cache: true
|
|
43
|
+
- run: uv sync --group dev --group test
|
|
44
|
+
- run: uv run mypy qingping_cgd1
|
|
45
|
+
|
|
46
|
+
test:
|
|
47
|
+
name: pytest (${{ matrix.os }})
|
|
48
|
+
runs-on: ${{ matrix.os }}
|
|
49
|
+
strategy:
|
|
50
|
+
fail-fast: false
|
|
51
|
+
matrix:
|
|
52
|
+
os: [ubuntu-latest, macos-latest]
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
55
|
+
with:
|
|
56
|
+
persist-credentials: false
|
|
57
|
+
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
58
|
+
with:
|
|
59
|
+
python-version: "3.14"
|
|
60
|
+
enable-cache: true
|
|
61
|
+
- run: uv sync --group test
|
|
62
|
+
- run: >-
|
|
63
|
+
uv run --no-sync pytest
|
|
64
|
+
--cov=qingping_cgd1
|
|
65
|
+
--cov-branch
|
|
66
|
+
--cov-report=term-missing
|
|
67
|
+
--cov-report=xml
|
|
68
|
+
- run: >-
|
|
69
|
+
uv run --no-sync coverage report
|
|
70
|
+
--include="*/codec.py,*/models.py,*/const.py" --fail-under=100
|
|
71
|
+
|
|
72
|
+
zizmor:
|
|
73
|
+
name: zizmor
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
permissions:
|
|
76
|
+
contents: read
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
79
|
+
with:
|
|
80
|
+
persist-credentials: false
|
|
81
|
+
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
82
|
+
- run: uvx zizmor@1.25.2 --persona=pedantic .github/workflows
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: release-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
name: Build distributions
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
20
|
+
with:
|
|
21
|
+
persist-credentials: false
|
|
22
|
+
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
23
|
+
with:
|
|
24
|
+
enable-cache: false
|
|
25
|
+
- run: uv build
|
|
26
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
name: Publish to PyPI
|
|
33
|
+
needs: build
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
environment: pypi
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write # OIDC token for trusted publishing; no stored secret
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
- uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# Tooling caches
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
coverage.xml
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Editor / OS
|
|
21
|
+
.DS_Store
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
|
|
25
|
+
# Scratch
|
|
26
|
+
.superpowers/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Coleman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qingping-cgd1
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python BLE library for the Qingping CGD1 alarm clock
|
|
5
|
+
Project-URL: Homepage, https://github.com/rjocoleman/qingping-cgd1
|
|
6
|
+
Project-URL: Source, https://github.com/rjocoleman/qingping-cgd1
|
|
7
|
+
Author: Robert Coleman
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: alarm-clock,ble,bluetooth,cgd1,qingping
|
|
11
|
+
Requires-Python: >=3.14.2
|
|
12
|
+
Requires-Dist: bleak-retry-connector>=3.5
|
|
13
|
+
Requires-Dist: bleak>=0.22
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# qingping-cgd1
|
|
17
|
+
|
|
18
|
+
Local BLE control for the Qingping CGD1 "Dove" alarm clock. Pure Python, no
|
|
19
|
+
cloud. Not affiliated with Qingping.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install qingping-cgd1
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Python 3.14.2+. Depends only on `bleak` and `bleak-retry-connector`.
|
|
28
|
+
|
|
29
|
+
## Passive sensor reading
|
|
30
|
+
|
|
31
|
+
Temperature, humidity, and battery ride in the BLE advertisement, so you can
|
|
32
|
+
read them without connecting to the device:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from qingping_cgd1 import parse_advertisement
|
|
36
|
+
|
|
37
|
+
reading = parse_advertisement(service_data) # the 0xfdcd service-data bytes
|
|
38
|
+
if reading is not None:
|
|
39
|
+
print(reading.temperature, reading.humidity, reading.battery)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Active control
|
|
43
|
+
|
|
44
|
+
Everything else - settings, alarms, time sync - needs a connection,
|
|
45
|
+
authenticated with a `bleak` `BLEDevice`:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from qingping_cgd1 import Alarm, QingpingCGD1Client, Weekday
|
|
49
|
+
|
|
50
|
+
async with QingpingCGD1Client(ble_device) as client:
|
|
51
|
+
settings = await client.read_settings()
|
|
52
|
+
await client.write_settings(settings)
|
|
53
|
+
|
|
54
|
+
alarms = await client.read_alarms()
|
|
55
|
+
await client.write_alarm(0, Alarm(
|
|
56
|
+
enabled=True,
|
|
57
|
+
hour=7,
|
|
58
|
+
minute=30,
|
|
59
|
+
days=frozenset({Weekday.MONDAY, Weekday.TUESDAY}),
|
|
60
|
+
snooze=True,
|
|
61
|
+
))
|
|
62
|
+
await client.delete_alarm(1)
|
|
63
|
+
|
|
64
|
+
await client.sync_time()
|
|
65
|
+
info = await client.read_firmware()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`QingpingCGD1Client` also has explicit `connect()`/`disconnect()` methods if
|
|
69
|
+
you'd rather not use it as a context manager. It authenticates with a
|
|
70
|
+
16-byte token (overridable via the `token` argument, default
|
|
71
|
+
`DEFAULT_AUTH_TOKEN`), retries a timed-out command once after a reconnect,
|
|
72
|
+
and disconnects automatically after `disconnect_delay` seconds of
|
|
73
|
+
inactivity (default 120s).
|
|
74
|
+
|
|
75
|
+
### Working out the next alarm
|
|
76
|
+
|
|
77
|
+
`next_alarm` takes the list from `read_alarms()` and a reference time, and
|
|
78
|
+
returns the soonest upcoming fire time (or `None` if nothing is enabled):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from datetime import datetime
|
|
82
|
+
|
|
83
|
+
from qingping_cgd1 import next_alarm
|
|
84
|
+
|
|
85
|
+
upcoming = next_alarm(alarms, datetime.now())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Protocol notes
|
|
89
|
+
|
|
90
|
+
This library is reverse-engineered, not an official SDK. The protocol was
|
|
91
|
+
mapped mainly by [MrBoombastic/clOwOck](https://github.com/MrBoombastic/clOwOck),
|
|
92
|
+
an Android app for the CGD1; this is a Python port of that work. A few things
|
|
93
|
+
worth knowing before you rely on it:
|
|
94
|
+
|
|
95
|
+
- There are no checksums on the wire. Frames are fixed-layout and unvalidated
|
|
96
|
+
by the device beyond their length.
|
|
97
|
+
- The 16-byte auth token is a per-device pairing secret, not a universal
|
|
98
|
+
key. A clock that's already paired (for example, one that's been used
|
|
99
|
+
with the official Qingping+ app) will reject the default token: the
|
|
100
|
+
device answers auth step 2 with `04 ff 02 00 01` (fail). You need to
|
|
101
|
+
unbind/reset the clock first - long-press its button until the
|
|
102
|
+
Bluetooth icon flashes, or remove it in the Qingping+ app - after which
|
|
103
|
+
it binds to the first token it's presented with. This library presents
|
|
104
|
+
its default token, and from then on that token authenticates every
|
|
105
|
+
time. The token is injectable via the `token` argument if you'd rather
|
|
106
|
+
manage pairing secrets yourself.
|
|
107
|
+
- The `0xfdcd` advertisement layout (`model | mac | temperature | humidity |
|
|
108
|
+
battery`) is confirmed from a real CGD1 capture (firmware 1.0.1_0130):
|
|
109
|
+
decoding it gave 20.0 C / 51.7% / 80%, matching the device's own
|
|
110
|
+
display. There are still two unknown/reserved byte pairs in the frame
|
|
111
|
+
whose meaning isn't established.
|
|
112
|
+
|
|
113
|
+
## Scope
|
|
114
|
+
|
|
115
|
+
Covers passive sensor reading from advertisements, settings read/write,
|
|
116
|
+
all 16 alarm slots, and time sync. Ringtone upload is not implemented.
|
|
117
|
+
|
|
118
|
+
## How this was built
|
|
119
|
+
|
|
120
|
+
Built largely with AI assistance (Claude) and tested against a real CGD1
|
|
121
|
+
(firmware 1.0.1_0130). Tested and working, but a spare-time project - no
|
|
122
|
+
warranty, no support promises.
|
|
123
|
+
|
|
124
|
+
## Licence
|
|
125
|
+
|
|
126
|
+
MIT.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# qingping-cgd1
|
|
2
|
+
|
|
3
|
+
Local BLE control for the Qingping CGD1 "Dove" alarm clock. Pure Python, no
|
|
4
|
+
cloud. Not affiliated with Qingping.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install qingping-cgd1
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires Python 3.14.2+. Depends only on `bleak` and `bleak-retry-connector`.
|
|
13
|
+
|
|
14
|
+
## Passive sensor reading
|
|
15
|
+
|
|
16
|
+
Temperature, humidity, and battery ride in the BLE advertisement, so you can
|
|
17
|
+
read them without connecting to the device:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from qingping_cgd1 import parse_advertisement
|
|
21
|
+
|
|
22
|
+
reading = parse_advertisement(service_data) # the 0xfdcd service-data bytes
|
|
23
|
+
if reading is not None:
|
|
24
|
+
print(reading.temperature, reading.humidity, reading.battery)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Active control
|
|
28
|
+
|
|
29
|
+
Everything else - settings, alarms, time sync - needs a connection,
|
|
30
|
+
authenticated with a `bleak` `BLEDevice`:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from qingping_cgd1 import Alarm, QingpingCGD1Client, Weekday
|
|
34
|
+
|
|
35
|
+
async with QingpingCGD1Client(ble_device) as client:
|
|
36
|
+
settings = await client.read_settings()
|
|
37
|
+
await client.write_settings(settings)
|
|
38
|
+
|
|
39
|
+
alarms = await client.read_alarms()
|
|
40
|
+
await client.write_alarm(0, Alarm(
|
|
41
|
+
enabled=True,
|
|
42
|
+
hour=7,
|
|
43
|
+
minute=30,
|
|
44
|
+
days=frozenset({Weekday.MONDAY, Weekday.TUESDAY}),
|
|
45
|
+
snooze=True,
|
|
46
|
+
))
|
|
47
|
+
await client.delete_alarm(1)
|
|
48
|
+
|
|
49
|
+
await client.sync_time()
|
|
50
|
+
info = await client.read_firmware()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`QingpingCGD1Client` also has explicit `connect()`/`disconnect()` methods if
|
|
54
|
+
you'd rather not use it as a context manager. It authenticates with a
|
|
55
|
+
16-byte token (overridable via the `token` argument, default
|
|
56
|
+
`DEFAULT_AUTH_TOKEN`), retries a timed-out command once after a reconnect,
|
|
57
|
+
and disconnects automatically after `disconnect_delay` seconds of
|
|
58
|
+
inactivity (default 120s).
|
|
59
|
+
|
|
60
|
+
### Working out the next alarm
|
|
61
|
+
|
|
62
|
+
`next_alarm` takes the list from `read_alarms()` and a reference time, and
|
|
63
|
+
returns the soonest upcoming fire time (or `None` if nothing is enabled):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from datetime import datetime
|
|
67
|
+
|
|
68
|
+
from qingping_cgd1 import next_alarm
|
|
69
|
+
|
|
70
|
+
upcoming = next_alarm(alarms, datetime.now())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Protocol notes
|
|
74
|
+
|
|
75
|
+
This library is reverse-engineered, not an official SDK. The protocol was
|
|
76
|
+
mapped mainly by [MrBoombastic/clOwOck](https://github.com/MrBoombastic/clOwOck),
|
|
77
|
+
an Android app for the CGD1; this is a Python port of that work. A few things
|
|
78
|
+
worth knowing before you rely on it:
|
|
79
|
+
|
|
80
|
+
- There are no checksums on the wire. Frames are fixed-layout and unvalidated
|
|
81
|
+
by the device beyond their length.
|
|
82
|
+
- The 16-byte auth token is a per-device pairing secret, not a universal
|
|
83
|
+
key. A clock that's already paired (for example, one that's been used
|
|
84
|
+
with the official Qingping+ app) will reject the default token: the
|
|
85
|
+
device answers auth step 2 with `04 ff 02 00 01` (fail). You need to
|
|
86
|
+
unbind/reset the clock first - long-press its button until the
|
|
87
|
+
Bluetooth icon flashes, or remove it in the Qingping+ app - after which
|
|
88
|
+
it binds to the first token it's presented with. This library presents
|
|
89
|
+
its default token, and from then on that token authenticates every
|
|
90
|
+
time. The token is injectable via the `token` argument if you'd rather
|
|
91
|
+
manage pairing secrets yourself.
|
|
92
|
+
- The `0xfdcd` advertisement layout (`model | mac | temperature | humidity |
|
|
93
|
+
battery`) is confirmed from a real CGD1 capture (firmware 1.0.1_0130):
|
|
94
|
+
decoding it gave 20.0 C / 51.7% / 80%, matching the device's own
|
|
95
|
+
display. There are still two unknown/reserved byte pairs in the frame
|
|
96
|
+
whose meaning isn't established.
|
|
97
|
+
|
|
98
|
+
## Scope
|
|
99
|
+
|
|
100
|
+
Covers passive sensor reading from advertisements, settings read/write,
|
|
101
|
+
all 16 alarm slots, and time sync. Ringtone upload is not implemented.
|
|
102
|
+
|
|
103
|
+
## How this was built
|
|
104
|
+
|
|
105
|
+
Built largely with AI assistance (Claude) and tested against a real CGD1
|
|
106
|
+
(firmware 1.0.1_0130). Tested and working, but a spare-time project - no
|
|
107
|
+
warranty, no support promises.
|
|
108
|
+
|
|
109
|
+
## Licence
|
|
110
|
+
|
|
111
|
+
MIT.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
pre-commit:
|
|
2
|
+
parallel: true
|
|
3
|
+
commands:
|
|
4
|
+
ruff-check:
|
|
5
|
+
glob: "*.py"
|
|
6
|
+
run: uv run ruff check --force-exclude {staged_files}
|
|
7
|
+
stage_fixed: true
|
|
8
|
+
ruff-format:
|
|
9
|
+
glob: "*.py"
|
|
10
|
+
run: uv run ruff format --check --force-exclude {staged_files}
|
|
11
|
+
mypy:
|
|
12
|
+
glob: "*.py"
|
|
13
|
+
run: uv run mypy qingping_cgd1
|
|
14
|
+
zizmor:
|
|
15
|
+
glob: ".github/workflows/*.{yml,yaml}"
|
|
16
|
+
run: uvx zizmor@1.25.2 {staged_files}
|
|
17
|
+
|
|
18
|
+
pre-push:
|
|
19
|
+
commands:
|
|
20
|
+
pytest:
|
|
21
|
+
run: uv run pytest
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qingping-cgd1"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pure-Python BLE library for the Qingping CGD1 alarm clock"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14.2"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Robert Coleman" }]
|
|
9
|
+
keywords = ["bluetooth", "ble", "qingping", "cgd1", "alarm-clock"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"bleak>=0.22",
|
|
12
|
+
"bleak-retry-connector>=3.5",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/rjocoleman/qingping-cgd1"
|
|
17
|
+
Source = "https://github.com/rjocoleman/qingping-cgd1"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"ruff>=0.15",
|
|
22
|
+
"mypy>=2.1",
|
|
23
|
+
]
|
|
24
|
+
test = [
|
|
25
|
+
"pytest>=8.3",
|
|
26
|
+
"pytest-asyncio>=0.25",
|
|
27
|
+
"pytest-cov>=6.0",
|
|
28
|
+
"freezegun>=1.5",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["qingping_cgd1"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
required-version = ">=0.14"
|
|
40
|
+
target-version = "py314"
|
|
41
|
+
line-length = 88
|
|
42
|
+
src = ["qingping_cgd1", "tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = [
|
|
46
|
+
"A", "ASYNC", "B", "BLE", "C4", "C90", "D", "DTZ", "E", "EM", "EXE", "F",
|
|
47
|
+
"FA", "FLY", "FURB", "G", "I", "ICN", "INP", "ISC", "LOG", "N", "PERF",
|
|
48
|
+
"PGH", "PIE", "PL", "PT", "PTH", "RET", "RSE", "RUF", "S", "SIM", "SLF",
|
|
49
|
+
"SLOT", "T20", "TC", "TID", "TRY", "UP", "W",
|
|
50
|
+
]
|
|
51
|
+
ignore = [
|
|
52
|
+
"D203", # incompatible with D211
|
|
53
|
+
"D213", # incompatible with D212
|
|
54
|
+
"E501", # the formatter owns line width
|
|
55
|
+
"ISC001", # conflicts with the formatter
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.per-file-ignores]
|
|
59
|
+
"tests/**/*.py" = [
|
|
60
|
+
"D", # tests document themselves through their names
|
|
61
|
+
"S101", # asserts are the point of tests
|
|
62
|
+
"PLR2004", # magic values are fine in tests
|
|
63
|
+
"SLF001", # tests reach into internals
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.isort]
|
|
67
|
+
force-sort-within-sections = true
|
|
68
|
+
combine-as-imports = true
|
|
69
|
+
known-first-party = ["qingping_cgd1"]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.mccabe]
|
|
72
|
+
max-complexity = 12
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.pydocstyle]
|
|
75
|
+
convention = "pep257"
|
|
76
|
+
|
|
77
|
+
[tool.mypy]
|
|
78
|
+
python_version = "3.14"
|
|
79
|
+
strict = true
|
|
80
|
+
warn_unreachable = true
|
|
81
|
+
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
|
|
82
|
+
follow_imports = "normal"
|
|
83
|
+
local_partial_types = true
|
|
84
|
+
no_implicit_reexport = true
|
|
85
|
+
|
|
86
|
+
[tool.pytest.ini_options]
|
|
87
|
+
testpaths = ["tests"]
|
|
88
|
+
pythonpath = ["."]
|
|
89
|
+
asyncio_mode = "auto"
|
|
90
|
+
addopts = "-ra --strict-markers --strict-config"
|
|
91
|
+
|
|
92
|
+
[tool.coverage.run]
|
|
93
|
+
branch = true
|
|
94
|
+
source = ["qingping_cgd1"]
|
|
95
|
+
|
|
96
|
+
[tool.coverage.report]
|
|
97
|
+
show_missing = true
|
|
98
|
+
exclude_also = [
|
|
99
|
+
"if TYPE_CHECKING:",
|
|
100
|
+
"raise NotImplementedError",
|
|
101
|
+
"@overload",
|
|
102
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pure-Python BLE library for the Qingping CGD1 alarm clock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import QingpingCGD1Client
|
|
6
|
+
from .codec import (
|
|
7
|
+
decode_alarm,
|
|
8
|
+
decode_settings,
|
|
9
|
+
encode_alarm,
|
|
10
|
+
encode_settings,
|
|
11
|
+
encode_time,
|
|
12
|
+
next_alarm,
|
|
13
|
+
parse_advertisement,
|
|
14
|
+
parse_connected_sensor,
|
|
15
|
+
)
|
|
16
|
+
from .const import ALARM_SLOT_COUNT, DEFAULT_AUTH_TOKEN
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
AuthError,
|
|
19
|
+
CommandError,
|
|
20
|
+
ConnectionError, # noqa: A004 - deliberate re-export, see exceptions.py
|
|
21
|
+
QingpingError,
|
|
22
|
+
)
|
|
23
|
+
from .models import (
|
|
24
|
+
Alarm,
|
|
25
|
+
DeviceInfo,
|
|
26
|
+
DeviceSettings,
|
|
27
|
+
Language,
|
|
28
|
+
SensorData,
|
|
29
|
+
Weekday,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ALARM_SLOT_COUNT",
|
|
36
|
+
"DEFAULT_AUTH_TOKEN",
|
|
37
|
+
"Alarm",
|
|
38
|
+
"AuthError",
|
|
39
|
+
"CommandError",
|
|
40
|
+
"ConnectionError",
|
|
41
|
+
"DeviceInfo",
|
|
42
|
+
"DeviceSettings",
|
|
43
|
+
"Language",
|
|
44
|
+
"QingpingCGD1Client",
|
|
45
|
+
"QingpingError",
|
|
46
|
+
"SensorData",
|
|
47
|
+
"Weekday",
|
|
48
|
+
"decode_alarm",
|
|
49
|
+
"decode_settings",
|
|
50
|
+
"encode_alarm",
|
|
51
|
+
"encode_settings",
|
|
52
|
+
"encode_time",
|
|
53
|
+
"next_alarm",
|
|
54
|
+
"parse_advertisement",
|
|
55
|
+
"parse_connected_sensor",
|
|
56
|
+
]
|