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.
@@ -0,0 +1,3 @@
1
+ """codex-usage-epd: push OpenAI Codex usage quota to a 4.2" e-paper display."""
2
+
3
+ __version__ = "2026.10.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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")