codex-usage-epd 2026.10.1__py3-none-any.whl
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.
- codex_usage_epd/__init__.py +3 -0
- codex_usage_epd/__main__.py +4 -0
- codex_usage_epd/api.py +258 -0
- codex_usage_epd/ble.py +345 -0
- codex_usage_epd/cli.py +270 -0
- codex_usage_epd/codex_usage_epd.yaml.example +30 -0
- codex_usage_epd/config.py +127 -0
- codex_usage_epd/model.py +73 -0
- codex_usage_epd/render.py +321 -0
- codex_usage_epd/rle.py +91 -0
- codex_usage_epd/token_usage.py +438 -0
- codex_usage_epd-2026.10.1.dist-info/METADATA +179 -0
- codex_usage_epd-2026.10.1.dist-info/RECORD +16 -0
- codex_usage_epd-2026.10.1.dist-info/WHEEL +4 -0
- codex_usage_epd-2026.10.1.dist-info/entry_points.txt +2 -0
- codex_usage_epd-2026.10.1.dist-info/licenses/LICENSE +661 -0
codex_usage_epd/api.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Fetch and parse OpenAI Codex usage from the OAuth `wham/usage` endpoint.
|
|
2
|
+
|
|
3
|
+
Replicates CodexBar's CodexOAuthUsageFetcher behaviour:
|
|
4
|
+
- credentials come from ~/.codex/auth.json (tokens.access_token/account_id)
|
|
5
|
+
- base URL honours the `chatgpt_base_url` key in ~/.codex/config.toml
|
|
6
|
+
- endpoint: GET <base>/wham/usage (or /api/codex/usage on custom bases)
|
|
7
|
+
- headers: Authorization: Bearer, User-Agent, Accept, ChatGPT-Account-Id
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import requests
|
|
18
|
+
except ImportError: # pragma: no cover
|
|
19
|
+
requests = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
from .model import Balance, DailyTokenUsage, ModelTokenUsage, Window
|
|
22
|
+
|
|
23
|
+
DEFAULT_CHATGPT_BASE = "https://chatgpt.com/backend-api/"
|
|
24
|
+
USAGE_PATH = "/wham/usage"
|
|
25
|
+
CODEX_USAGE_PATH = "/api/codex/usage"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UsageFetchError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def codex_home() -> Path:
|
|
33
|
+
override = os_environ_get("CODEX_HOME")
|
|
34
|
+
if override:
|
|
35
|
+
return Path(override)
|
|
36
|
+
return Path.home() / ".codex"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def os_environ_get(key: str, default: str = "") -> str:
|
|
40
|
+
import os
|
|
41
|
+
|
|
42
|
+
return os.environ.get(key, default)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_auth(auth_file: Path) -> tuple[str, str | None]:
|
|
46
|
+
"""Return (access_token, account_id) from ~/.codex/auth.json."""
|
|
47
|
+
if not auth_file.exists():
|
|
48
|
+
raise UsageFetchError(f"auth file not found: {auth_file}. Run `codex` to log in.")
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(auth_file.read_text())
|
|
51
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
52
|
+
raise UsageFetchError(f"failed to decode {auth_file}: {e}") from e
|
|
53
|
+
|
|
54
|
+
tokens = data.get("tokens") or {}
|
|
55
|
+
access_token = tokens.get("access_token") or tokens.get("accessToken") or ""
|
|
56
|
+
if not access_token:
|
|
57
|
+
api_key = (data.get("OPENAI_API_KEY") or "").strip()
|
|
58
|
+
if api_key:
|
|
59
|
+
raise UsageFetchError(
|
|
60
|
+
"auth.json holds an API key, not an OAuth login. "
|
|
61
|
+
"`wham/usage` requires ChatGPT subscription auth (`codex login`)."
|
|
62
|
+
)
|
|
63
|
+
raise UsageFetchError(f"no access_token in {auth_file}")
|
|
64
|
+
account_id = tokens.get("account_id") or tokens.get("accountId") or None
|
|
65
|
+
return access_token, account_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_base_url(home: Path) -> str:
|
|
69
|
+
"""Read chatgpt_base_url from config.toml (like CodexBar)."""
|
|
70
|
+
cfg = home / "config.toml"
|
|
71
|
+
if cfg.exists():
|
|
72
|
+
try:
|
|
73
|
+
for raw_line in cfg.read_text().splitlines():
|
|
74
|
+
line = raw_line.split("#", 1)[0].strip()
|
|
75
|
+
if not line:
|
|
76
|
+
continue
|
|
77
|
+
if "=" in line:
|
|
78
|
+
key, _, value = line.partition("=")
|
|
79
|
+
if key.strip() == "chatgpt_base_url":
|
|
80
|
+
value = value.strip().strip('"').strip("'").rstrip("/")
|
|
81
|
+
if value:
|
|
82
|
+
return value
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
return DEFAULT_CHATGPT_BASE.rstrip("/")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_usage_url(home: Path) -> str:
|
|
89
|
+
base = resolve_base_url(home)
|
|
90
|
+
path = USAGE_PATH if base.endswith("/backend-api") else CODEX_USAGE_PATH
|
|
91
|
+
return base + path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def fetch_usage(access_token: str, account_id: str | None, url: str, timeout: float = 15.0,
|
|
95
|
+
attempts: int = 3) -> dict:
|
|
96
|
+
if requests is None:
|
|
97
|
+
raise UsageFetchError("requests is required. Install with: pip install requests")
|
|
98
|
+
|
|
99
|
+
headers = {
|
|
100
|
+
"Authorization": f"Bearer {access_token}",
|
|
101
|
+
"User-Agent": "codex-usage-epd/0.1",
|
|
102
|
+
"Accept": "application/json",
|
|
103
|
+
"Origin": "https://chatgpt.com",
|
|
104
|
+
"Referer": "https://chatgpt.com/",
|
|
105
|
+
}
|
|
106
|
+
if account_id:
|
|
107
|
+
headers["ChatGPT-Account-Id"] = account_id
|
|
108
|
+
|
|
109
|
+
last_err: Exception | None = None
|
|
110
|
+
for attempt in range(1, attempts + 1):
|
|
111
|
+
try:
|
|
112
|
+
resp = requests.get(url, headers=headers, timeout=timeout) # type: ignore[name-defined]
|
|
113
|
+
except Exception as e: # network / TLS errors -> retry with backoff
|
|
114
|
+
last_err = e
|
|
115
|
+
if attempt < attempts:
|
|
116
|
+
import time
|
|
117
|
+
|
|
118
|
+
time.sleep(1.5 * attempt)
|
|
119
|
+
continue
|
|
120
|
+
raise UsageFetchError(f"network error: {e}") from e
|
|
121
|
+
|
|
122
|
+
if resp.status_code in (401, 403):
|
|
123
|
+
raise UsageFetchError(
|
|
124
|
+
f"HTTP {resp.status_code}: token expired/invalid. Run `codex` to re-authenticate."
|
|
125
|
+
)
|
|
126
|
+
if resp.status_code != 200:
|
|
127
|
+
raise UsageFetchError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
128
|
+
try:
|
|
129
|
+
return resp.json()
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
raise UsageFetchError(f"invalid JSON response: {e}") from e
|
|
132
|
+
raise UsageFetchError(f"network error: {last_err}") # pragma: no cover
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Parsing
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _to_float(value) -> float | None:
|
|
140
|
+
if value is None:
|
|
141
|
+
return None
|
|
142
|
+
if isinstance(value, bool):
|
|
143
|
+
return None
|
|
144
|
+
if isinstance(value, (int, float)):
|
|
145
|
+
return float(value)
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
try:
|
|
148
|
+
return float(value.strip())
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _to_int(value) -> int | None:
|
|
155
|
+
f = _to_float(value)
|
|
156
|
+
return int(f) if f is not None else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _window(raw, name: str, label: str) -> Window | None:
|
|
160
|
+
if not isinstance(raw, dict):
|
|
161
|
+
return None
|
|
162
|
+
used = _to_float(raw.get("used_percent"))
|
|
163
|
+
if used is None:
|
|
164
|
+
used = 0.0
|
|
165
|
+
return Window(
|
|
166
|
+
name=name,
|
|
167
|
+
label=label,
|
|
168
|
+
used_percent=used,
|
|
169
|
+
resets_at=_to_int(raw.get("reset_at")) or _to_int(raw.get("resets_at")),
|
|
170
|
+
limit_seconds=_to_int(raw.get("limit_window_seconds")),
|
|
171
|
+
reset_after_seconds=_to_int(raw.get("reset_after_seconds")),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _name_for_window(reset_at: int | None, limit_seconds: int | None) -> str:
|
|
176
|
+
# wham/usage windows have no "name" field; infer from duration.
|
|
177
|
+
if limit_seconds:
|
|
178
|
+
if limit_seconds == 5 * 3600:
|
|
179
|
+
return "five_hour"
|
|
180
|
+
if limit_seconds == 7 * 24 * 3600:
|
|
181
|
+
return "weekly"
|
|
182
|
+
if limit_seconds == 30 * 24 * 3600:
|
|
183
|
+
return "thirty_day"
|
|
184
|
+
return f"win_{limit_seconds // 60}m"
|
|
185
|
+
return "window"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _windows_from_rate_limit(rate: dict) -> list[Window]:
|
|
189
|
+
out: list[Window] = []
|
|
190
|
+
pw = _window(rate.get("primary_window"), "five_hour", "5H")
|
|
191
|
+
sw = _window(rate.get("secondary_window"), "weekly", "WK")
|
|
192
|
+
if pw:
|
|
193
|
+
pw.name = _name_for_window(pw.resets_at, pw.limit_seconds)
|
|
194
|
+
pw.label = _label_for(pw.name)
|
|
195
|
+
out.append(pw)
|
|
196
|
+
if sw:
|
|
197
|
+
sw.name = _name_for_window(sw.resets_at, sw.limit_seconds)
|
|
198
|
+
sw.label = _label_for(sw.name)
|
|
199
|
+
out.append(sw)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _label_for(name: str) -> str:
|
|
204
|
+
return {
|
|
205
|
+
"five_hour": "5H",
|
|
206
|
+
"weekly": "WK",
|
|
207
|
+
"thirty_day": "30D",
|
|
208
|
+
}.get(name, name.upper()[:3])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_usage(raw: dict) -> Balance:
|
|
212
|
+
plan_type = raw.get("plan_type") or None
|
|
213
|
+
|
|
214
|
+
credits_raw = raw.get("credits")
|
|
215
|
+
has_credits = False
|
|
216
|
+
credits_unlimited = False
|
|
217
|
+
credits_balance = None
|
|
218
|
+
if isinstance(credits_raw, dict):
|
|
219
|
+
has_credits = bool(credits_raw.get("has_credits", False))
|
|
220
|
+
credits_unlimited = bool(credits_raw.get("unlimited", False))
|
|
221
|
+
credits_balance = _to_float(credits_raw.get("balance"))
|
|
222
|
+
|
|
223
|
+
rate = raw.get("rate_limit") or {}
|
|
224
|
+
windows = _windows_from_rate_limit(rate) if isinstance(rate, dict) else []
|
|
225
|
+
|
|
226
|
+
return Balance(
|
|
227
|
+
plan_type=plan_type,
|
|
228
|
+
windows=windows,
|
|
229
|
+
credits_balance=credits_balance,
|
|
230
|
+
credits_unlimited=credits_unlimited,
|
|
231
|
+
has_credits=has_credits,
|
|
232
|
+
source="oauth",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def sample_balance() -> Balance:
|
|
237
|
+
"""Synthetic snapshot used by --selftest (no network / no device)."""
|
|
238
|
+
local_now = datetime.now().astimezone()
|
|
239
|
+
now = int(local_now.timestamp())
|
|
240
|
+
return Balance(
|
|
241
|
+
plan_type="plus",
|
|
242
|
+
windows=[
|
|
243
|
+
Window("five_hour", "5H", used_percent=17.0, resets_at=now + 3600, limit_seconds=5 * 3600, reset_after_seconds=3600),
|
|
244
|
+
Window("weekly", "WK", used_percent=36.0, resets_at=now + 3 * 86400, limit_seconds=7 * 86400, reset_after_seconds=3 * 86400),
|
|
245
|
+
],
|
|
246
|
+
models=[
|
|
247
|
+
ModelTokenUsage("gpt-5.6-sol", 12_840_000),
|
|
248
|
+
ModelTokenUsage("gpt-6-astra", 6_275_000),
|
|
249
|
+
ModelTokenUsage("gpt-5.6-luna", 925_000),
|
|
250
|
+
],
|
|
251
|
+
has_credits=False,
|
|
252
|
+
source="sample",
|
|
253
|
+
daily_usage=[
|
|
254
|
+
DailyTokenUsage(local_now.date() - timedelta(days=7 - i), tokens)
|
|
255
|
+
for i, tokens in enumerate([8_200_000, 12_500_000, 0, 9_700_000, 18_300_000, 14_100_000, 22_600_000])
|
|
256
|
+
],
|
|
257
|
+
fetched_at=local_now,
|
|
258
|
+
)
|
codex_usage_epd/ble.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""BLE push of the two bitplanes to an EPD-nRF5 device.
|
|
2
|
+
|
|
3
|
+
Protocol (EPD-nRF5/EPD/EPD_service.c + html/js/main.js):
|
|
4
|
+
- service 62750001-d828-918d-fb46-b6c11c675aec, char 62750002
|
|
5
|
+
- after connecting, subscribe to notifications (CCCD write w/ response),
|
|
6
|
+
then INIT 0x01 [model_id] w/ response; the device replies with the epd
|
|
7
|
+
config, then "mtu=244 rle=1" and "t=..."
|
|
8
|
+
- WRITE_IMG 0x30 [flags] [payload] flags: bit0=1 red plane, bit1=1 first
|
|
9
|
+
chunk (begin), bit2=1 RLE-encoded payload
|
|
10
|
+
- REFRESH 0x05, SLEEP 0x06 (w/ response)
|
|
11
|
+
- chunk size = (max_data_len - 2); the device reports max_data_len = MTU - 3.
|
|
12
|
+
|
|
13
|
+
Slot firmware (epdiy.cn web client) adds:
|
|
14
|
+
- SET_SLOT 0x31 [action, slot] action=0 select write slot, action=1 display
|
|
15
|
+
- device announces "slots=<count> <usedMask> [selected]" (usedMask decimal)
|
|
16
|
+
after connecting; sending SET_SLOT [0, slot] before INIT routes WRITE_IMG
|
|
17
|
+
into that slot.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from .rle import raw_chunks, rle_chunks
|
|
27
|
+
|
|
28
|
+
BLE_EPD_SVC = "62750001-d828-918d-fb46-b6c11c675aec"
|
|
29
|
+
BLE_EPD_CHAR = "62750002-d828-918d-fb46-b6c11c675aec"
|
|
30
|
+
|
|
31
|
+
CMD_INIT = 0x01
|
|
32
|
+
CMD_CLEAR = 0x02
|
|
33
|
+
CMD_REFRESH = 0x05
|
|
34
|
+
CMD_SLEEP = 0x06
|
|
35
|
+
CMD_WRITE_IMG = 0x30
|
|
36
|
+
CMD_SET_SLOT = 0x31
|
|
37
|
+
CMD_SET_CONFIG = 0x90
|
|
38
|
+
|
|
39
|
+
DEFAULT_DEVICE_NAME = "NRF_EPD"
|
|
40
|
+
_MTU_RE = re.compile(rb"mtu=(\d+)")
|
|
41
|
+
_SLOTS_RE = re.compile(rb"slots=(\d+)\s+(\d+|0x[0-9A-Fa-f]+)(?:\s+(-?\d+))?")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BlePushError(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _resolve_device(device: str, scan_timeout: float, scan_retries: int = 3) -> str:
|
|
49
|
+
if device.lower() != "auto":
|
|
50
|
+
return device # caller passes a concrete address or name substring
|
|
51
|
+
from bleak import BleakScanner
|
|
52
|
+
|
|
53
|
+
print(f"[ble] scanning for '{DEFAULT_DEVICE_NAME}' ...")
|
|
54
|
+
seen: dict[str, str] = {}
|
|
55
|
+
for attempt in range(1, max(1, scan_retries) + 1):
|
|
56
|
+
if attempt > 1:
|
|
57
|
+
print(f"[ble] scan attempt {attempt}/{scan_retries} ...")
|
|
58
|
+
target: asyncio.Future[tuple[str, str]] = asyncio.get_running_loop().create_future()
|
|
59
|
+
|
|
60
|
+
def _cb(device_, _adv, target=target):
|
|
61
|
+
if device_.name:
|
|
62
|
+
seen[device_.address] = device_.name
|
|
63
|
+
if device_.name and DEFAULT_DEVICE_NAME in device_.name and not target.done():
|
|
64
|
+
target.set_result((device_.name, device_.address))
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
async with BleakScanner(detection_callback=_cb):
|
|
68
|
+
name, address = await asyncio.wait_for(target, timeout=scan_timeout)
|
|
69
|
+
except asyncio.TimeoutError:
|
|
70
|
+
continue
|
|
71
|
+
print(f"[ble] found {name} @ {address}")
|
|
72
|
+
return address
|
|
73
|
+
|
|
74
|
+
names = [f"{n} {a}" for a, n in seen.items()]
|
|
75
|
+
raise BlePushError(
|
|
76
|
+
f"no '{DEFAULT_DEVICE_NAME}' device found after {max(1, scan_retries)} scans. seen: {names}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _request_mtu(client, mtu: int) -> None:
|
|
81
|
+
# bleak 3.x dropped request_mtu on some backends (macOS/CB negotiates MTU
|
|
82
|
+
# automatically); call it only when the backend exposes it.
|
|
83
|
+
request_mtu = getattr(client, "request_mtu", None)
|
|
84
|
+
if request_mtu is None:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
await request_mtu(mtu)
|
|
88
|
+
except Exception as exc: # noqa: BLE001 — backend may not support MTU exchange
|
|
89
|
+
print(f"[ble] request_mtu skipped: {exc}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Notifier:
|
|
93
|
+
"""Synchronous notify callback (bleak 3.x callbacks are sync)."""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
self.config: bytes | None = None
|
|
97
|
+
self.mtu: int | None = None
|
|
98
|
+
self.messages: list[str] = []
|
|
99
|
+
self.slots_count: int = 0
|
|
100
|
+
self.slots_used_mask: int = 0
|
|
101
|
+
self.slots_selected: int | None = None
|
|
102
|
+
|
|
103
|
+
def __call__(self, _characteristic: object, data: bytearray) -> None:
|
|
104
|
+
blob = bytes(data)
|
|
105
|
+
if self.config is None and len(blob) >= 11 and not blob.startswith((b"mtu=", b"t=", b"slots=")):
|
|
106
|
+
self.config = blob
|
|
107
|
+
m = _MTU_RE.search(blob)
|
|
108
|
+
if m:
|
|
109
|
+
self.mtu = int(m.group(1))
|
|
110
|
+
sm = _SLOTS_RE.search(blob)
|
|
111
|
+
if sm:
|
|
112
|
+
self.slots_count = int(sm.group(1))
|
|
113
|
+
mask = sm.group(2).decode(errors="replace")
|
|
114
|
+
self.slots_used_mask = int(mask, 16) if mask.lower().startswith("0x") else int(mask, 10)
|
|
115
|
+
self.slots_selected = int(sm.group(3)) if sm.group(3) else None
|
|
116
|
+
if blob.startswith((b"mtu=", b"t=", b"slots=")):
|
|
117
|
+
self.messages.append(blob.decode(errors="replace"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _config_bytes_to_str(cfg: bytes) -> str:
|
|
121
|
+
if len(cfg) < 11:
|
|
122
|
+
return cfg.hex()
|
|
123
|
+
names = ["mosi", "sclk", "cs", "dc", "rst", "busy", "bs", "model", "wakeup", "led", "en"]
|
|
124
|
+
return ", ".join(f"{names[i]}={cfg[i]:#04x}" for i in range(11))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_slot(slot_cfg, notifier: _Notifier) -> int | None:
|
|
128
|
+
"""Map the configured ``slot`` to a concrete slot index to write into.
|
|
129
|
+
|
|
130
|
+
- ``None`` / ``"none"`` / ``-1`` -> no SET_SLOT command (raw behaviour)
|
|
131
|
+
- ``int >= 0`` -> that exact slot
|
|
132
|
+
- ``"auto"`` -> first free slot per the device's
|
|
133
|
+
usedMask; falls back to the currently
|
|
134
|
+
selected slot, then slot 0
|
|
135
|
+
Returns ``None`` (no slot command) when the device does not advertise
|
|
136
|
+
slot support and the caller asked for ``"auto"``.
|
|
137
|
+
"""
|
|
138
|
+
if slot_cfg is None:
|
|
139
|
+
return None
|
|
140
|
+
if isinstance(slot_cfg, str):
|
|
141
|
+
s = slot_cfg.strip().lower()
|
|
142
|
+
if s in ("none", "off", "disabled"):
|
|
143
|
+
return None
|
|
144
|
+
if s == "auto":
|
|
145
|
+
count = notifier.slots_count or 0
|
|
146
|
+
if count <= 0:
|
|
147
|
+
return None
|
|
148
|
+
mask = notifier.slots_used_mask or 0
|
|
149
|
+
for i in range(count):
|
|
150
|
+
if not (mask >> i) & 1:
|
|
151
|
+
return i
|
|
152
|
+
cur = notifier.slots_selected
|
|
153
|
+
return cur if cur is not None and 0 <= cur < count else 0
|
|
154
|
+
try:
|
|
155
|
+
slot_cfg = int(s)
|
|
156
|
+
except ValueError:
|
|
157
|
+
raise BlePushError(f"invalid ble.slot value: {slot_cfg!r}")
|
|
158
|
+
slot_idx = int(slot_cfg)
|
|
159
|
+
if slot_idx < 0:
|
|
160
|
+
return None
|
|
161
|
+
if notifier.slots_count and slot_idx >= notifier.slots_count:
|
|
162
|
+
print(f"[ble] warning: slot {slot_idx} >= device slot count {notifier.slots_count}")
|
|
163
|
+
return slot_idx
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def probe(
|
|
167
|
+
device: str,
|
|
168
|
+
model_id: int,
|
|
169
|
+
mtu: int,
|
|
170
|
+
scan_timeout: float,
|
|
171
|
+
scan_retries: int = 3,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Connect, subscribe, INIT, and report what the device says."""
|
|
174
|
+
from bleak import BleakClient
|
|
175
|
+
|
|
176
|
+
address = await _resolve_device(device, scan_timeout, scan_retries)
|
|
177
|
+
notifier = _Notifier()
|
|
178
|
+
async with BleakClient(address, timeout=30) as client:
|
|
179
|
+
await _request_mtu(client, mtu)
|
|
180
|
+
await client.start_notify(BLE_EPD_CHAR, notifier)
|
|
181
|
+
print(f"[probe] connected to {address}")
|
|
182
|
+
print(f"[probe] negotiated mtu: {getattr(client, 'mtu_size', '?')}")
|
|
183
|
+
try:
|
|
184
|
+
version_char = client.services.get_characteristic("62750003-d828-918d-fb46-b6c11c675aec")
|
|
185
|
+
if version_char:
|
|
186
|
+
ver = await client.read_gatt_char(version_char)
|
|
187
|
+
print(f"[probe] firmware version: 0x{ver[0]:02x}")
|
|
188
|
+
except Exception: # noqa: BLE001, S110 — version characteristic is optional
|
|
189
|
+
pass
|
|
190
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_INIT, model_id & 0xFF]), response=True)
|
|
191
|
+
deadline = time.monotonic() + 2.0
|
|
192
|
+
while time.monotonic() < deadline and notifier.mtu is None:
|
|
193
|
+
await asyncio.sleep(0.05)
|
|
194
|
+
if notifier.config is not None:
|
|
195
|
+
print(f"[probe] epd config: {_config_bytes_to_str(notifier.config)}")
|
|
196
|
+
print(f"[probe] mtu reported by device: {notifier.mtu}")
|
|
197
|
+
for msg in notifier.messages:
|
|
198
|
+
print(f"[probe] device: {msg}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def test_screen(
|
|
202
|
+
device: str,
|
|
203
|
+
model_id: int,
|
|
204
|
+
mtu: int,
|
|
205
|
+
scan_timeout: float,
|
|
206
|
+
scan_retries: int = 3,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Minimal firmware path check: INIT + CLEAR(with refresh). The screen
|
|
209
|
+
should flash to white and the REFRESH inside CLEAR should block a few
|
|
210
|
+
seconds (busy wait), proving the EPD driver is bound and commands work."""
|
|
211
|
+
from bleak import BleakClient
|
|
212
|
+
|
|
213
|
+
address = await _resolve_device(device, scan_timeout, scan_retries)
|
|
214
|
+
notifier = _Notifier()
|
|
215
|
+
async with BleakClient(address, timeout=30) as client:
|
|
216
|
+
await _request_mtu(client, mtu)
|
|
217
|
+
await client.start_notify(BLE_EPD_CHAR, notifier)
|
|
218
|
+
t0 = time.monotonic()
|
|
219
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_INIT, model_id & 0xFF]), response=True)
|
|
220
|
+
print(f"[test] INIT done in {time.monotonic() - t0:.2f}s")
|
|
221
|
+
t1 = time.monotonic()
|
|
222
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_CLEAR]), response=True)
|
|
223
|
+
print(f"[test] CLEAR (full refresh) done in {time.monotonic() - t1:.2f}s")
|
|
224
|
+
print(f"[test] mtu reported: {notifier.mtu}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def push_display(
|
|
228
|
+
planes: tuple[bytes, bytes],
|
|
229
|
+
model_id: int,
|
|
230
|
+
device: str,
|
|
231
|
+
mtu: int = 247,
|
|
232
|
+
scan_timeout: float = 10.0,
|
|
233
|
+
scan_retries: int = 3,
|
|
234
|
+
interleave: int = 50,
|
|
235
|
+
sleep_after_push: bool = False,
|
|
236
|
+
patch_wakeup_pin: bool = True,
|
|
237
|
+
pacing_ms: float = 0.0,
|
|
238
|
+
hold_after_refresh: float = 15.0,
|
|
239
|
+
slot: int | str | None = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Mirror the proven-good web client sequence (html/js/main.js sendimg).
|
|
242
|
+
|
|
243
|
+
Order matters: INIT [0x01] (no model byte; firmware falls back to the
|
|
244
|
+
stored config.model_id) -> WRITE_IMG chunks (RLE, interleave write/response)
|
|
245
|
+
-> REFRESH -> stay connected while the panel finishes its busy refresh.
|
|
246
|
+
The web client never sends SLEEP and does not disconnect during refresh;
|
|
247
|
+
sending SLEEP or dropping the link mid-refresh aborts the image.
|
|
248
|
+
|
|
249
|
+
On slot firmware (epdiy.cn) a ``SET_SLOT [0, slot]`` is sent before INIT,
|
|
250
|
+
exactly like the web client, so WRITE_IMG lands in the chosen slot instead
|
|
251
|
+
of overwriting whatever slot the device currently has selected.
|
|
252
|
+
"""
|
|
253
|
+
from bleak import BleakClient
|
|
254
|
+
|
|
255
|
+
address = await _resolve_device(device, scan_timeout, scan_retries)
|
|
256
|
+
notifier = _Notifier()
|
|
257
|
+
|
|
258
|
+
async with BleakClient(address, timeout=30) as client:
|
|
259
|
+
await _request_mtu(client, mtu)
|
|
260
|
+
await client.start_notify(BLE_EPD_CHAR, notifier)
|
|
261
|
+
|
|
262
|
+
# "auto" needs the device's slot announcement before picking a free slot
|
|
263
|
+
if isinstance(slot, str) and slot.strip().lower() == "auto":
|
|
264
|
+
deadline = time.monotonic() + 2.0
|
|
265
|
+
while time.monotonic() < deadline and notifier.slots_count == 0:
|
|
266
|
+
await asyncio.sleep(0.05)
|
|
267
|
+
use_slot = _resolve_slot(slot, notifier)
|
|
268
|
+
if use_slot is not None:
|
|
269
|
+
occupied = bool(notifier.slots_count and (notifier.slots_used_mask >> use_slot) & 1)
|
|
270
|
+
print(
|
|
271
|
+
f"[ble] SET_SLOT write slot {use_slot}"
|
|
272
|
+
+ (" (occupied, will overwrite)" if occupied else "")
|
|
273
|
+
)
|
|
274
|
+
await client.write_gatt_char(
|
|
275
|
+
BLE_EPD_CHAR, bytes([CMD_SET_SLOT, 0x00, use_slot & 0xFF]), response=True
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# INIT without a model byte, exactly like the web client. The device
|
|
279
|
+
# replies with the epd config + "mtu=<n> rle=1" + "t=<unix>".
|
|
280
|
+
t0 = time.monotonic()
|
|
281
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_INIT]), response=True)
|
|
282
|
+
deadline = time.monotonic() + 2.0
|
|
283
|
+
while time.monotonic() < deadline and notifier.mtu is None:
|
|
284
|
+
await asyncio.sleep(0.05)
|
|
285
|
+
print(f"[ble] INIT + mtu wait: {time.monotonic() - t0:.2f}s")
|
|
286
|
+
if notifier.config is not None:
|
|
287
|
+
print(f"[ble] epd config: {_config_bytes_to_str(notifier.config)}")
|
|
288
|
+
if notifier.slots_count:
|
|
289
|
+
print(
|
|
290
|
+
f"[ble] device slots: count={notifier.slots_count} "
|
|
291
|
+
f"used=0x{notifier.slots_used_mask:x} selected={notifier.slots_selected}"
|
|
292
|
+
)
|
|
293
|
+
for msg in notifier.messages:
|
|
294
|
+
print(f"[ble] device: {msg}")
|
|
295
|
+
|
|
296
|
+
negotiated = getattr(client, "mtu_size", 0) or 23
|
|
297
|
+
max_data_len = notifier.mtu or (negotiated - 3)
|
|
298
|
+
chunk_size = max_data_len - 2
|
|
299
|
+
if chunk_size < 8:
|
|
300
|
+
raise BlePushError(f"max data len too small for transfers: {max_data_len}")
|
|
301
|
+
print(f"[ble] negotiated mtu={negotiated} device max_data_len={max_data_len} chunk={chunk_size}B")
|
|
302
|
+
|
|
303
|
+
# if configured, force wakeup_pin=0xFF so the device keeps re-advertising
|
|
304
|
+
if patch_wakeup_pin and notifier.config and len(notifier.config) >= 11 and notifier.config[8] != 0xFF:
|
|
305
|
+
patched = bytearray(notifier.config[:11])
|
|
306
|
+
patched[8] = 0xFF
|
|
307
|
+
print("[ble] patching config wakeup_pin -> 0xFF")
|
|
308
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_SET_CONFIG]) + bytes(patched), response=True)
|
|
309
|
+
await asyncio.sleep(0.1)
|
|
310
|
+
|
|
311
|
+
for red_plane, plane_bytes in enumerate(planes):
|
|
312
|
+
compressed = rle_chunks(plane_bytes, chunk_size)
|
|
313
|
+
use_rle = sum(len(c) for c in compressed) < len(plane_bytes)
|
|
314
|
+
chunks = compressed if use_rle else raw_chunks(plane_bytes, chunk_size)
|
|
315
|
+
print(
|
|
316
|
+
f"[ble] {'red ' if red_plane else 'black'} plane "
|
|
317
|
+
f"{len(plane_bytes)}B -> {len(chunks)} chunks "
|
|
318
|
+
f"({'RLE' if use_rle else 'raw'})"
|
|
319
|
+
)
|
|
320
|
+
for i, chunk in enumerate(chunks):
|
|
321
|
+
flags = 0x00
|
|
322
|
+
if red_plane:
|
|
323
|
+
flags |= 0x01
|
|
324
|
+
if i == 0:
|
|
325
|
+
flags |= 0x02 # begin
|
|
326
|
+
if use_rle:
|
|
327
|
+
flags |= 0x04
|
|
328
|
+
msg = bytes([CMD_WRITE_IMG, flags]) + chunk
|
|
329
|
+
response = (i % (interleave + 1)) == 0
|
|
330
|
+
await client.write_gatt_char(BLE_EPD_CHAR, msg, response=response)
|
|
331
|
+
if pacing_ms:
|
|
332
|
+
await asyncio.sleep(pacing_ms / 1000.0)
|
|
333
|
+
|
|
334
|
+
# REFRESH without response: the firmware blocks in SSD16xx_WaitBusy
|
|
335
|
+
# while the panel refreshes, so a write-with-response would just sit
|
|
336
|
+
# waiting for an ATT ack the device only sends after the busy-wait
|
|
337
|
+
# finishes. Fire-and-forget, then hold the connection open long enough
|
|
338
|
+
# for the panel to finish; the firmware sleeps the panel on disconnect
|
|
339
|
+
# and would abort a mid-flight refresh.
|
|
340
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_REFRESH]), response=False)
|
|
341
|
+
print(f"[ble] REFRESH sent (no-response), holding {hold_after_refresh}s for panel refresh...")
|
|
342
|
+
await asyncio.sleep(hold_after_refresh)
|
|
343
|
+
if sleep_after_push:
|
|
344
|
+
await client.write_gatt_char(BLE_EPD_CHAR, bytes([CMD_SLEEP]), response=True)
|
|
345
|
+
print("[ble] display sleeping")
|