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/cli.py ADDED
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """codex-usage-epd: fetch OpenAI Codex usage and push it to an e-paper display.
3
+
4
+ Primary data source: OAuth `wham/usage` endpoint (see CodexBar).
5
+ Display protocol is compatible with the EPD-nRF5 web client (RLE + bitplanes).
6
+
7
+ Commands:
8
+ --selftest render sample data + verify RLE round-trip (no network/BLE)
9
+ --dry-run fetch + render preview.png, print summary (no BLE)
10
+ --once fetch + render + push to display once
11
+ --loop push every N minutes forever
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import json
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from .api import (
24
+ UsageFetchError,
25
+ build_usage_url,
26
+ fetch_usage,
27
+ parse_usage,
28
+ read_auth,
29
+ sample_balance,
30
+ )
31
+ from .ble import BlePushError, probe, push_display, test_screen
32
+ from .config import expand_user, load_config
33
+ from .render import image_to_planes, planes_to_rgb, render_dashboard, resolve_font_path
34
+ from .token_usage import read_token_usage
35
+
36
+
37
+ def parse_args(argv: list[str]) -> argparse.Namespace:
38
+ p = argparse.ArgumentParser(description="Codex usage -> e-paper display")
39
+ p.add_argument("--config", default=None, help="path to YAML config (default: repo config/ or bundled)")
40
+ p.add_argument("--debug", action="store_true", help="dump raw wham/usage JSON to tmp/usage_dump.json")
41
+ p.add_argument("--font", default=None, help="override render.font")
42
+ p.add_argument("--show-history", action=argparse.BooleanOptionalAction, default=None,
43
+ help="show the Last 7 days token chart (overrides render.show_history)")
44
+ p.add_argument("--sample", action="store_true", help="use synthetic sample data (no network)")
45
+ p.add_argument("--force", action="store_true", help="with --init: overwrite an existing config")
46
+ g = p.add_mutually_exclusive_group()
47
+ g.add_argument("--init", action="store_true", help="generate a config from the template (no network/device)")
48
+ g.add_argument("--selftest", action="store_true", help="render sample data, no network/device")
49
+ g.add_argument("--dry-run", action="store_true", help="fetch + render preview.png, no BLE")
50
+ g.add_argument("--probe", action="store_true", help="connect + INIT, print device config/mtu, no image")
51
+ g.add_argument("--test-screen", action="store_true", help="INIT + CLEAR full refresh, check firmware path")
52
+ g.add_argument("--once", action="store_true", help="fetch + render + push once")
53
+ g.add_argument("--loop", action="store_true", help="push every N minutes forever")
54
+ return p.parse_args(argv)
55
+
56
+
57
+ def load_balance(args: argparse.Namespace, cfg: dict) -> tuple:
58
+ """Return (balance, raw_dict_or_None)."""
59
+ codex = cfg["codex"]
60
+ auth_file = expand_user(codex["auth_file"])
61
+ access_token, account_id = read_auth(Path(auth_file))
62
+ url = build_usage_url(Path(auth_file).parent)
63
+ raw = fetch_usage(access_token, account_id, url, timeout=codex.get("timeout", 15.0))
64
+ if args.debug:
65
+ dump_dir = Path(__file__).resolve().parent.parent / "tmp"
66
+ dump_dir.mkdir(exist_ok=True)
67
+ dump_path = dump_dir / "usage_dump.json"
68
+ dump_path.write_text(json.dumps(raw, indent=2))
69
+ print(f"[debug] raw wham/usage JSON -> {dump_path}")
70
+ balance = parse_usage(raw)
71
+ balance.models, balance.daily_usage = read_token_usage(
72
+ Path(auth_file).parent, now=balance.fetched_at, limit=None,
73
+ history_days=7 if cfg["render"].get("show_history", False) else 0,
74
+ )
75
+ return balance, raw
76
+
77
+
78
+ def render(balance, cfg: dict, font_path: str) -> tuple[bytes, bytes]:
79
+ render_cfg = cfg["render"]
80
+ img = render_dashboard(
81
+ balance,
82
+ font_path=font_path,
83
+ warn_threshold=render_cfg.get("warn_threshold", 20.0),
84
+ show_history=render_cfg.get("show_history", False),
85
+ width=cfg["display"]["width"],
86
+ height=cfg["display"]["height"],
87
+ )
88
+ preview = render_cfg.get("preview", "preview.png")
89
+ img.save(preview)
90
+ print(f"[render] saved {preview} ({img.size[0]}x{img.size[1]})")
91
+ return image_to_planes(img)
92
+
93
+
94
+ def run_selftest(cfg: dict, font_path: str) -> int:
95
+ balance = sample_balance()
96
+ render_cfg = cfg["render"]
97
+ img = render_dashboard(
98
+ balance,
99
+ font_path=font_path,
100
+ warn_threshold=render_cfg.get("warn_threshold", 20.0),
101
+ show_history=render_cfg.get("show_history", False),
102
+ width=cfg["display"]["width"],
103
+ height=cfg["display"]["height"],
104
+ )
105
+ preview = render_cfg.get("preview", "preview.png")
106
+ img.save(preview)
107
+ bw, red = image_to_planes(img)
108
+ print(f"[selftest] rendered {img.size[0]}x{img.size[1]} -> {preview}")
109
+ print(f"[selftest] planes: bw={len(bw)}B red={len(red)}B")
110
+
111
+ # RLE round-trip: compress -> decompress == original, chunks self-contained
112
+ from .rle import _rle_decompressed, rle_chunks
113
+
114
+ chunk_size = 242 # MTU 247 - 5
115
+ for name, plane in (("bw", bw), ("red", red)):
116
+ chunks = rle_chunks(plane, chunk_size)
117
+ for i, ch in enumerate(chunks):
118
+ if len(ch) > chunk_size:
119
+ print(f"[selftest] FAIL chunk {name}[{i}] {len(ch)} > {chunk_size}")
120
+ return 1
121
+ joined = b"".join(chunks)
122
+ decoded = _rle_decompressed(joined, len(plane))
123
+ if decoded != plane:
124
+ print(f"[selftest] FAIL {name} RLE round-trip ({len(decoded)} != {len(plane)})")
125
+ return 1
126
+ print(f"[selftest] ok {name}: {len(plane)}B -> {len(joined)}B RLE in {len(chunks)} chunks")
127
+
128
+ # plane round-trip: every pure black/white/red pixel must decode back
129
+ back = planes_to_rgb(bw, red, img.size[0], img.size[1])
130
+ pa, pb = img.load(), back.load()
131
+ assert pa is not None and pb is not None
132
+ bad = 0
133
+ checked = 0
134
+ for yy in range(img.size[1]):
135
+ for xx in range(img.size[0]):
136
+ a = pa[xx, yy]
137
+ if a in ((0, 0, 0), (255, 255, 255), (255, 0, 0)):
138
+ checked += 1
139
+ if pb[xx, yy] != a:
140
+ bad += 1
141
+ print(f"[selftest] pure-colour round-trip: {checked} px checked, {bad} wrong")
142
+ if bad == 0:
143
+ print("[selftest] PASS")
144
+ return 0
145
+ print("[selftest] FAIL: bitplane encode/decode mismatch")
146
+ return 1
147
+
148
+
149
+ async def run_push(planes, cfg: dict) -> None:
150
+ ble = cfg["ble"]
151
+ display = cfg["display"]
152
+ await push_display(
153
+ planes,
154
+ model_id=int(display["model_id"]),
155
+ device=ble["device"],
156
+ mtu=int(ble.get("mtu", 247)),
157
+ scan_timeout=float(ble.get("scan_timeout", 10.0)),
158
+ scan_retries=int(ble.get("scan_retries", 3)),
159
+ interleave=int(ble.get("interleave", 50)),
160
+ sleep_after_push=bool(display.get("sleep_after_push", False)),
161
+ patch_wakeup_pin=bool(ble.get("patch_wakeup_pin", True)),
162
+ pacing_ms=float(ble.get("pacing_ms", 0.0)),
163
+ hold_after_refresh=float(ble.get("hold_after_refresh", 15.0)),
164
+ slot=ble.get("slot", 0),
165
+ )
166
+
167
+
168
+ async def run_probe(cfg: dict) -> None:
169
+ ble = cfg["ble"]
170
+ display = cfg["display"]
171
+ await probe(
172
+ device=ble["device"],
173
+ model_id=int(display["model_id"]),
174
+ mtu=int(ble.get("mtu", 247)),
175
+ scan_timeout=float(ble.get("scan_timeout", 10.0)),
176
+ scan_retries=int(ble.get("scan_retries", 3)),
177
+ )
178
+
179
+
180
+ async def run_test_screen(cfg: dict) -> None:
181
+ ble = cfg["ble"]
182
+ display = cfg["display"]
183
+ await test_screen(
184
+ device=ble["device"],
185
+ model_id=int(display["model_id"]),
186
+ mtu=int(ble.get("mtu", 247)),
187
+ scan_timeout=float(ble.get("scan_timeout", 10.0)),
188
+ scan_retries=int(ble.get("scan_retries", 3)),
189
+ )
190
+
191
+
192
+ def main(argv: list[str] | None = None) -> int:
193
+ args = parse_args(argv if argv is not None else sys.argv[1:])
194
+
195
+ if args.init:
196
+ try:
197
+ from .config import init_config
198
+
199
+ dest = init_config(args.config, force=args.force)
200
+ except FileExistsError as e:
201
+ print(f"[error] {e}", file=sys.stderr)
202
+ return 1
203
+ print(f"[init] wrote {dest}")
204
+ return 0
205
+
206
+ cfg = load_config(args.config)
207
+ if args.show_history is not None:
208
+ cfg["render"]["show_history"] = args.show_history
209
+ print(f"[cfg] using {cfg['_path']}")
210
+
211
+ if args.selftest:
212
+ font_path = resolve_font_path(args.font or cfg["render"].get("font", ""))
213
+ return run_selftest(cfg, font_path)
214
+
215
+ if args.probe:
216
+ try:
217
+ asyncio.run(run_probe(cfg))
218
+ return 0
219
+ except BlePushError as e:
220
+ print(f"[error] {e}", file=sys.stderr)
221
+ return 1
222
+
223
+ if args.test_screen:
224
+ try:
225
+ asyncio.run(run_test_screen(cfg))
226
+ return 0
227
+ except BlePushError as e:
228
+ print(f"[error] {e}", file=sys.stderr)
229
+ return 1
230
+
231
+ def do_once() -> int:
232
+ if args.sample:
233
+ balance = sample_balance()
234
+ print("[fetch] using synthetic sample snapshot")
235
+ else:
236
+ balance, _raw = load_balance(args, cfg)
237
+ print("[fetch] usage snapshot:")
238
+ print(balance.summary())
239
+ font_path = resolve_font_path(args.font or cfg["render"].get("font", ""))
240
+ planes = render(balance, cfg, font_path)
241
+ if args.dry_run:
242
+ print("[dry-run] skipping BLE push")
243
+ return 0
244
+ asyncio.run(run_push(planes, cfg))
245
+ return 0
246
+
247
+ if args.dry_run or args.once:
248
+ try:
249
+ return do_once()
250
+ except (UsageFetchError, BlePushError) as e:
251
+ print(f"[error] {e}", file=sys.stderr)
252
+ return 1
253
+
254
+ if args.loop:
255
+ interval = int(cfg["schedule"].get("interval_minutes", 5)) * 60
256
+ print(f"[loop] pushing every {interval // 60} min (Ctrl-C to stop)")
257
+ while True:
258
+ try:
259
+ do_once()
260
+ except (UsageFetchError, BlePushError) as e:
261
+ print(f"[error] {e}", file=sys.stderr)
262
+ time.sleep(interval)
263
+ return 0
264
+
265
+ print("no action given (use --selftest / --probe / --dry-run / --once / --loop)", file=sys.stderr)
266
+ return 2
267
+
268
+
269
+ if __name__ == "__main__":
270
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ display:
2
+ width: 400
3
+ height: 300
4
+ model_id: 0x02 # SSD1683/SSD1619 400x300 B/W/R
5
+ sleep_after_push: false # web client never sends SLEEP; early sleep aborts refresh
6
+
7
+ ble:
8
+ device: "auto" # "auto" (scan NRF_EPD) | MAC address | name substring
9
+ mtu: 247
10
+ scan_timeout: 10 # seconds per scan attempt
11
+ scan_retries: 3 # retry the scan before giving up (missed adv packets)
12
+ interleave: 50 # match web client: 50 writes w/o response, then 1 with
13
+ patch_wakeup_pin: true
14
+ pacing_ms: 0.0 # no pacing (web client sends back-to-back)
15
+ hold_after_refresh: 20.0 # stay connected until panel busy refresh completes
16
+ slot: 0 # epdiy.cn slot firmware: image slot to write (0..N-1);
17
+ # "auto" = first free slot; -1 / "none" = no SET_SLOT cmd
18
+
19
+ codex:
20
+ auth_file: "~/.codex/auth.json"
21
+ timeout: 15
22
+
23
+ render:
24
+ font: "" # empty = auto-detect; or path to a .ttf
25
+ warn_threshold: 20 # remaining % below this renders the bar red
26
+ preview: "preview.png"
27
+ show_history: false # opt in to the Last 7 days chart beside today's usage
28
+
29
+ schedule:
30
+ interval_minutes: 60 # used by --loop; launchd uses its plist StartInterval
@@ -0,0 +1,127 @@
1
+ """Config + CLI helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ DEFAULT_CONFIG_NAME = "codex_usage_epd.yaml"
8
+ TEMPLATE_NAME = "codex_usage_epd.yaml.example"
9
+
10
+ USER_CONFIG_DIR = "~/.config/codex-usage-epd"
11
+
12
+ _DEFAULTS = {
13
+ "display": {
14
+ "width": 400,
15
+ "height": 300,
16
+ "model_id": 0x02,
17
+ "sleep_after_push": False,
18
+ },
19
+ "ble": {
20
+ "device": "auto",
21
+ "mtu": 247,
22
+ "scan_timeout": 10.0,
23
+ "scan_retries": 3,
24
+ "interleave": 50,
25
+ "patch_wakeup_pin": True,
26
+ "pacing_ms": 0.0,
27
+ "hold_after_refresh": 20.0,
28
+ "slot": 0,
29
+ },
30
+ "codex": {
31
+ "auth_file": "~/.codex/auth.json",
32
+ "timeout": 15.0,
33
+ },
34
+ "render": {
35
+ "font": "",
36
+ "warn_threshold": 20.0,
37
+ "preview": "preview.png",
38
+ "show_history": False,
39
+ },
40
+ "schedule": {
41
+ "interval_minutes": 5,
42
+ },
43
+ }
44
+
45
+
46
+ def _bundled_template_text() -> str:
47
+ repo = Path(__file__).resolve().parent.parent / "config" / TEMPLATE_NAME
48
+ if repo.exists():
49
+ return repo.read_text(encoding="utf-8")
50
+ import importlib.resources
51
+
52
+ res = importlib.resources.files("codex_usage_epd").joinpath(TEMPLATE_NAME)
53
+ return res.read_text(encoding="utf-8")
54
+
55
+
56
+ def init_config(path: str | None = None, force: bool = False) -> str:
57
+ """Generate a real config file from the bundled template.
58
+
59
+ Defaults to <repo root>/config/codex_usage_epd.yaml when the repo checkout
60
+ is present, otherwise to ~/.config/codex-usage-epd/codex_usage_epd.yaml.
61
+ Refuses to overwrite an existing file unless force=True.
62
+ Returns the path written.
63
+ """
64
+ dest = _default_config_path(path)
65
+ if dest.exists() and not force:
66
+ raise FileExistsError(f"config already exists: {dest} (use --force to overwrite)")
67
+ dest.parent.mkdir(parents=True, exist_ok=True)
68
+ dest.write_text(_bundled_template_text())
69
+ return str(dest)
70
+
71
+
72
+ def _default_config_path(path: str | None) -> Path:
73
+ if path:
74
+ return Path(path)
75
+ repo = Path(__file__).resolve().parent.parent / "config" / DEFAULT_CONFIG_NAME
76
+ if repo.parent.exists():
77
+ return repo
78
+ return Path(USER_CONFIG_DIR).expanduser() / DEFAULT_CONFIG_NAME
79
+
80
+
81
+ def load_config(path: str | None) -> dict:
82
+ import yaml
83
+
84
+ if path:
85
+ cfg_path = Path(path)
86
+ if not cfg_path.exists():
87
+ raise FileNotFoundError(f"config not found: {cfg_path}")
88
+ else:
89
+ # 1) repo checkout layout: <repo root>/config/codex_usage_epd.yaml
90
+ repo_cfg = Path(__file__).resolve().parent.parent / "config" / DEFAULT_CONFIG_NAME
91
+ # 2) installed package: ~/.config/codex-usage-epd/codex_usage_epd.yaml
92
+ user_cfg = Path(USER_CONFIG_DIR).expanduser() / DEFAULT_CONFIG_NAME
93
+ if repo_cfg.exists():
94
+ cfg_path = repo_cfg
95
+ elif user_cfg.exists():
96
+ cfg_path = user_cfg
97
+ else:
98
+ # 3) bundled template as the fallback default
99
+ return _merge(yaml.safe_load(_bundled_template_text()) or {}, f"<bundled>:{TEMPLATE_NAME}")
100
+ with cfg_path.open() as f:
101
+ user_cfg = yaml.safe_load(f) or {}
102
+ return _merge(user_cfg, str(cfg_path))
103
+
104
+
105
+ def _merge(user_cfg: dict, src: str) -> dict:
106
+ merged = {}
107
+ for section, defaults in _DEFAULTS.items():
108
+ merged[section] = dict(defaults)
109
+ if isinstance(user_cfg.get(section), dict):
110
+ merged[section].update(user_cfg[section])
111
+
112
+ # top-level flat keys are accepted too
113
+ for key, value in user_cfg.items():
114
+ if key not in _DEFAULTS:
115
+ merged[key] = value
116
+
117
+ merged["_path"] = src
118
+ return merged
119
+
120
+
121
+ def expand_user(text: str) -> str:
122
+ return str(Path(text).expanduser())
123
+
124
+
125
+ def resolve_codex_home() -> Path:
126
+ override = __import__("os").environ.get("CODEX_HOME")
127
+ return Path(override) if override else Path.home() / ".codex"
@@ -0,0 +1,73 @@
1
+ """Data model for Codex usage quota snapshots.
2
+
3
+ Quota field names mirror the `wham/usage` payload decoded by CodexBar's
4
+ CodexOAuthUsageFetcher. Per-model token totals come from today's local Codex
5
+ session logs, like CodexBar's local token-history view.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import date, datetime
12
+
13
+
14
+ @dataclass
15
+ class Window:
16
+ name: str # window identity e.g. "five_hour", "weekly"
17
+ label: str # short display label e.g. "5H", "WK"
18
+ used_percent: float # 0..100 used
19
+ resets_at: int | None # unix epoch seconds
20
+ limit_seconds: int | None
21
+ reset_after_seconds: int | None = None
22
+
23
+ @property
24
+ def remaining_percent(self) -> float:
25
+ return max(0.0, min(100.0, 100.0 - self.used_percent))
26
+
27
+
28
+ @dataclass
29
+ class ModelTokenUsage:
30
+ id: str # e.g. "gpt-5.6-sol"
31
+ tokens: int
32
+
33
+
34
+ @dataclass
35
+ class DailyTokenUsage:
36
+ day: date
37
+ tokens: int
38
+
39
+
40
+ @dataclass
41
+ class Balance:
42
+ plan_type: str | None
43
+ windows: list[Window] = field(default_factory=list) # global windows
44
+ models: list[ModelTokenUsage] = field(default_factory=list) # all today's models, ranked
45
+ credits_balance: float | None = None
46
+ credits_unlimited: bool = False
47
+ has_credits: bool = False
48
+ fetched_at: datetime = field(default_factory=datetime.now)
49
+ source: str = "oauth" # "oauth" | "sample"
50
+ daily_usage: list[DailyTokenUsage] = field(default_factory=list)
51
+
52
+ @property
53
+ def total_tokens_today(self) -> int:
54
+ return sum(model.tokens for model in self.models)
55
+
56
+ def window(self, name: str) -> Window | None:
57
+ for w in self.windows:
58
+ if w.name == name:
59
+ return w
60
+ return None
61
+
62
+ def summary(self) -> str:
63
+ lines = [f"plan: {self.plan_type}"]
64
+ for w in self.windows:
65
+ lines.append(f" {w.name}: used {w.used_percent:.1f}% remaining {w.remaining_percent:.1f}%")
66
+ lines.append(f" today Total: {self.total_tokens_today} tokens")
67
+ for m in self.models:
68
+ lines.append(f" today {m.id}: {m.tokens} tokens")
69
+ for day in self.daily_usage:
70
+ lines.append(f" {day.day.isoformat()}: {day.tokens} tokens")
71
+ if self.has_credits:
72
+ lines.append(f" credits: balance={self.credits_balance} unlimited={self.credits_unlimited}")
73
+ return "\n".join(lines)