warpedpinball 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.
Files changed (33) hide show
  1. warpedpinball-0.1.0/.github/workflows/ci.yml +51 -0
  2. warpedpinball-0.1.0/.github/workflows/publish.yml +37 -0
  3. warpedpinball-0.1.0/.gitignore +11 -0
  4. warpedpinball-0.1.0/LICENSE +21 -0
  5. warpedpinball-0.1.0/PKG-INFO +324 -0
  6. warpedpinball-0.1.0/README.md +293 -0
  7. warpedpinball-0.1.0/docs/README.md +30 -0
  8. warpedpinball-0.1.0/docs/address-maps.md +135 -0
  9. warpedpinball-0.1.0/docs/http-api.md +132 -0
  10. warpedpinball-0.1.0/docs/memory.md +163 -0
  11. warpedpinball-0.1.0/notes/DECISIONS.md +64 -0
  12. warpedpinball-0.1.0/notes/TODO.md +87 -0
  13. warpedpinball-0.1.0/pyproject.toml +61 -0
  14. warpedpinball-0.1.0/tests/conftest.py +165 -0
  15. warpedpinball-0.1.0/tests/test_addresses.py +149 -0
  16. warpedpinball-0.1.0/tests/test_auth.py +61 -0
  17. warpedpinball-0.1.0/tests/test_cli.py +85 -0
  18. warpedpinball-0.1.0/tests/test_connect.py +71 -0
  19. warpedpinball-0.1.0/tests/test_discovery.py +68 -0
  20. warpedpinball-0.1.0/tests/test_http.py +171 -0
  21. warpedpinball-0.1.0/tests/test_machine.py +336 -0
  22. warpedpinball-0.1.0/tests/test_usb.py +125 -0
  23. warpedpinball-0.1.0/warpedpinball/__init__.py +178 -0
  24. warpedpinball-0.1.0/warpedpinball/addresses.py +216 -0
  25. warpedpinball-0.1.0/warpedpinball/auth.py +60 -0
  26. warpedpinball-0.1.0/warpedpinball/cli.py +215 -0
  27. warpedpinball-0.1.0/warpedpinball/discovery.py +126 -0
  28. warpedpinball-0.1.0/warpedpinball/exceptions.py +104 -0
  29. warpedpinball-0.1.0/warpedpinball/machine.py +547 -0
  30. warpedpinball-0.1.0/warpedpinball/models.py +99 -0
  31. warpedpinball-0.1.0/warpedpinball/transports/__init__.py +136 -0
  32. warpedpinball-0.1.0/warpedpinball/transports/http.py +157 -0
  33. warpedpinball-0.1.0/warpedpinball/transports/usb.py +188 -0
@@ -0,0 +1,51 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - name: Install ruff
17
+ run: pip install ruff
18
+ - name: Lint
19
+ run: ruff check .
20
+
21
+ test:
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+ - name: Install
33
+ run: pip install -e ".[dev,usb]"
34
+ - name: Test
35
+ run: python -m pytest
36
+
37
+ build:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: actions/setup-python@v5
42
+ with:
43
+ python-version: "3.12"
44
+ - name: Install build
45
+ run: pip install build
46
+ - name: Build distributions
47
+ run: python -m build
48
+ - uses: actions/upload-artifact@v4
49
+ with:
50
+ name: dist
51
+ path: dist/
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - name: Install build
17
+ run: pip install build
18
+ - name: Build distributions
19
+ run: python -m build
20
+ - uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - uses: actions/download-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+ - name: Publish to PyPI (Trusted Publishing)
37
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Warped Pinball
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,324 @@
1
+ Metadata-Version: 2.4
2
+ Name: warpedpinball
3
+ Version: 0.1.0
4
+ Summary: Python client for Warped Pinball Vector boards (HTTP, USB serial, and UDP discovery)
5
+ Project-URL: Homepage, https://github.com/warped-pinball/python-library
6
+ Project-URL: Issues, https://github.com/warped-pinball/python-library/issues
7
+ Author: Warped Pinball
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: pinball,raspberry-pi-pico,vector,warped-pinball
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Games/Entertainment :: Arcade
20
+ Classifier: Topic :: System :: Hardware
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: requests>=2.25
23
+ Provides-Extra: dev
24
+ Requires-Dist: build; extra == 'dev'
25
+ Requires-Dist: pyserial>=3.5; extra == 'dev'
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Provides-Extra: usb
29
+ Requires-Dist: pyserial>=3.5; extra == 'usb'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # warpedpinball
33
+
34
+ [![CI](https://github.com/warped-pinball/python-library/actions/workflows/ci.yml/badge.svg)](https://github.com/warped-pinball/python-library/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/warpedpinball.svg)](https://pypi.org/project/warpedpinball/)
36
+
37
+ A Python client for **Warped Pinball "Vector"** boards — the WiFi board
38
+ (Raspberry Pi Pico 2W) that lives inside a pinball machine and exposes:
39
+
40
+ - an **HTTP API** on port 80 (scores, players, live game status, raw SRAM
41
+ access, firmware updates, and more),
42
+ - **UDP discovery** on port 37020, so boards on your LAN can be found by name,
43
+ - a **USB serial tunnel** of the same HTTP routes, for when you're plugged in
44
+ directly, and
45
+ - **HMAC-SHA256 challenge/response authentication** for mutating routes.
46
+
47
+ This library wraps all of that behind one `Machine` object, plus a `vector`
48
+ command-line tool.
49
+
50
+ Detailed guides live in [`docs/`](docs/README.md) — including
51
+ [reading & writing memory](docs/memory.md), [address maps](docs/address-maps.md),
52
+ and the [HTTP API reference](docs/http-api.md) for the raw
53
+ `/api/address/read` / `/api/address/write` routes.
54
+
55
+ ## Install
56
+
57
+ Requires Python 3.9+.
58
+
59
+ ```bash
60
+ pip install warpedpinball
61
+ ```
62
+
63
+ For USB serial support (adds [pyserial](https://pypi.org/project/pyserial/)):
64
+
65
+ ```bash
66
+ pip install "warpedpinball[usb]"
67
+ ```
68
+
69
+ ## Quickstart
70
+
71
+ ```python
72
+ import warpedpinball
73
+
74
+ # Find every Vector board on the LAN (UDP broadcast, port 37020)
75
+ for m in warpedpinball.discover(timeout=5):
76
+ print(m.name, m.ip)
77
+
78
+ # Connect by machine name (case-insensitive; unique prefix/substring works too)
79
+ machine = warpedpinball.connect("elvira", password="hunter2")
80
+
81
+ # ...or by IP address (skips discovery)
82
+ machine = warpedpinball.connect("192.168.1.42")
83
+
84
+ # ...or over USB (auto-picks the port when exactly one board is attached)
85
+ machine = warpedpinball.connect_usb()
86
+
87
+ # Machines are context managers
88
+ with warpedpinball.connect("elvira") as m:
89
+ print(m.version())
90
+ print(m.game_status())
91
+ print(m.leaderboard())
92
+ ```
93
+
94
+ `connect()` raises `MachineNotFoundError` (listing the names it *did* see) or
95
+ `AmbiguousMachineError` (listing the candidates) when a name doesn't resolve to
96
+ exactly one board.
97
+
98
+ ## Authentication
99
+
100
+ Read-only routes need no credentials. Mutating routes (reboot, writes, updates,
101
+ score resets, ...) are protected by an HMAC-SHA256 challenge/response scheme.
102
+ You just provide the password — the library fetches a single-use challenge and
103
+ signs each request automatically and invisibly. Give it the password any of
104
+ three ways:
105
+
106
+ ```python
107
+ # 1. At connect time
108
+ m = warpedpinball.connect("elvira", password="hunter2")
109
+
110
+ # 2. On the machine object
111
+ m.password = "hunter2"
112
+
113
+ # 3. Via the environment
114
+ # export VECTOR_PASSWORD=hunter2
115
+ ```
116
+
117
+ Over **USB no password is needed at all** — the firmware trusts physical
118
+ access and skips HMAC for requests arriving on the serial port.
119
+
120
+ To validate credentials up front instead of failing on the first write:
121
+
122
+ ```python
123
+ if not m.verify_password():
124
+ raise SystemExit("wrong password")
125
+ ```
126
+
127
+ If an authenticated call is attempted with no password configured, you get an
128
+ `AuthenticationRequiredError` before any network traffic; a rejected signature
129
+ raises `AuthenticationError`.
130
+
131
+ ## API tour
132
+
133
+ ### Common wrappers
134
+
135
+ ```python
136
+ m.version() # firmware version
137
+ m.game_status() # live game status (scores, ball in play, ...)
138
+ m.leaderboard() # high scores
139
+ m.players() # player roster
140
+ m.update_player(id=3, initials="MAX", full_name="Max M.") # authenticated
141
+
142
+ # Firmware updates, with streamed progress
143
+ info = m.check_for_updates() # note: 10 s server-side cooldown
144
+ m.apply_update(progress=lambda rec: print(rec.get("percent"), rec.get("log")))
145
+ m.wait_until_reachable() # poll /api/version until the board is back
146
+
147
+ # Logs (authenticated; 10 s server-side cooldown) — streamed as bytes
148
+ for chunk in m.logs():
149
+ print(chunk.decode(errors="replace"), end="")
150
+
151
+ # Power
152
+ m.reboot() # reboot the Vector board
153
+ m.reboot_game() # power-cycle the pinball machine itself
154
+ m.wait_until_reachable(timeout=120)
155
+ ```
156
+
157
+ Other wrappers include `machine_id()`, `game_name()`, `active_config()`,
158
+ `wifi_status()`, `faults()`, `peers()`, `tournament()`,
159
+ `claimable_scores()` / `claim_score()`, `export_scores()` / `import_scores()`,
160
+ `reset_leaderboard()` / `reset_tournament()`, `date()` / `set_date()`, and the
161
+ adjustments family (`adjustments()`, `capture_adjustments()`,
162
+ `restore_adjustments()`, `name_adjustment()`).
163
+
164
+ ### The raw escape hatch
165
+
166
+ Every firmware route is reachable even without a wrapper:
167
+
168
+ ```python
169
+ m.call("/api/game/name")
170
+ m.call("/api/player/update",
171
+ body={"id": 1, "initials": "ABC"},
172
+ authenticated=True)
173
+
174
+ # Streaming variant: an iterator of raw byte chunks
175
+ for chunk in m.call_stream("/api/memory-snapshot"):
176
+ ...
177
+ ```
178
+
179
+ Bodies are serialized to JSON exactly once; the same string is signed and
180
+ transmitted, so authenticated `call()`s work for any route.
181
+
182
+ ### Watching a game live
183
+
184
+ `watch_game()` polls `/api/game/status` and yields `GameEvent`s
185
+ (`game_started`, `game_ended`, `ball_changed`, `score_changed`,
186
+ `status_changed`) forever:
187
+
188
+ ```python
189
+ for event in m.watch_game(interval=1.0): # interval is clamped to >= 0.5 s
190
+ if event.type == "score_changed":
191
+ print(f"player {event.player}: {event.old} -> {event.new}")
192
+ elif event.type == "game_ended":
193
+ print("game over", event.status)
194
+ ```
195
+
196
+ ## Named memory addresses
197
+
198
+ The firmware exposes raw SRAM reads/writes, but it can't store names. An
199
+ `AddressMap` maps friendly names to `(offset, length, encoding)` so mod makers
200
+ can ship an address map for a specific game ROM alongside their code:
201
+
202
+ ```python
203
+ from warpedpinball import AddressMap
204
+
205
+ amap = AddressMap(active_config="elvira_l4")
206
+ amap.define("credits", 0x2134) # raw single byte
207
+ amap.define("player1_score", 0x0200, length=4, encoding="bcd")
208
+ amap.define("counter", 0x0300, length=2, encoding="le_uint")
209
+
210
+ m.addresses = amap
211
+ score = m.read("player1_score") # decoded int
212
+ m.write("credits", 5)
213
+
214
+ # Raw offsets work too
215
+ m.read(0x2134) # single byte -> int
216
+ m.read(0x0200, count=4) # -> bytes
217
+ ```
218
+
219
+ Built-in encodings are `"bcd"` (packed BCD, most-significant byte first — the
220
+ classic Williams/Bally score format), `"le_uint"`, and `"be_uint"`; you can
221
+ also pass a custom `(decode, encode)` callable pair. Reads and writes are
222
+ automatically chunked at 256 bytes per request (the firmware's limit).
223
+
224
+ ### Sharing maps as JSON
225
+
226
+ ```python
227
+ amap.save("elvira_l4.json")
228
+ amap = AddressMap.load("elvira_l4.json") # warns if the machine's
229
+ # active_config doesn't match
230
+ ```
231
+
232
+ There's also a registry convention: drop a map at
233
+ `~/.warpedpinball/addressmaps/<active_config>.json` and `connect()` /
234
+ `connect_usb()` will auto-load it based on the machine's
235
+ `/api/game/active_config` value (only when that directory exists and you didn't
236
+ pass `addresses=` yourself). `AddressMap.load_registry("elvira_l4")` does the
237
+ lookup explicitly.
238
+
239
+ ### Finding addresses
240
+
241
+ Use full-memory snapshots and diff them to hunt for the byte you care about:
242
+
243
+ ```python
244
+ before = m.memory_snapshot() # full SRAM dump (streamed)
245
+ # ... add a credit on the machine ...
246
+ after = m.memory_snapshot()
247
+
248
+ for offset, old, new in m.diff_snapshots(before, after):
249
+ print(f"{offset:#06x}: {old} -> {new}")
250
+ ```
251
+
252
+ ## Being kind to the device
253
+
254
+ The board is a single-threaded microcontroller. The library helps you not
255
+ overwhelm it:
256
+
257
+ - **Requests are serialized per machine** — each `Machine` holds a lock, so
258
+ even multi-threaded programs send one request at a time (auth challenges are
259
+ single-use, so this also keeps signing correct).
260
+ - **`watch_game()` enforces a minimum 0.5 s poll interval.**
261
+ - **Some routes have server-side cooldowns**: `/api/logs` 10 s,
262
+ `/api/update/check` 10 s, `/api/adjustments/restore` 5 s. Hitting one raises
263
+ `CooldownError` with a best-effort `retry_after` hint (seconds).
264
+ - **`RateLimitedError`** means the device had too many outstanding auth
265
+ challenges (it holds ~10; expired ones are purged on each challenge request).
266
+ The HTTP transport already retries this a few times internally; if it still
267
+ surfaces, sleep briefly and retry.
268
+
269
+ ```python
270
+ import time
271
+ from warpedpinball import CooldownError
272
+
273
+ try:
274
+ m.check_for_updates()
275
+ except CooldownError as exc:
276
+ time.sleep(exc.retry_after or 10)
277
+ m.check_for_updates()
278
+ ```
279
+
280
+ ## Command line
281
+
282
+ Installing the package adds a `vector` command:
283
+
284
+ ```
285
+ vector discover # find boards on the LAN
286
+ vector status elvira # show live game status
287
+ vector version elvira # show firmware version
288
+ vector leaders elvira # show the leaderboard
289
+ vector read elvira 0x2134 --count 4 # read SRAM bytes
290
+ vector write elvira 0x2134 5 --password hunter2
291
+ vector snapshot elvira -o dump.bin # dump full SRAM
292
+ vector update elvira --password hunter2 # check for + apply a firmware update
293
+ ```
294
+
295
+ Each machine-targeting subcommand accepts a machine name or IP address, plus
296
+ `--password/-p` (or `$VECTOR_PASSWORD`), `--timeout` for discovery, and
297
+ `--usb [PORT]` to go over USB serial instead of the network.
298
+
299
+ ## Errors
300
+
301
+ All exceptions derive from `warpedpinball.VectorError`:
302
+
303
+ | Exception | Meaning |
304
+ | --- | --- |
305
+ | `TransportError` | Connection, timeout, or protocol-level failure |
306
+ | `MachineNotFoundError` | Discovery found no matching machine (`.seen_names` lists what it did see) |
307
+ | `AmbiguousMachineError` | A name matched more than one machine (`.candidates`) |
308
+ | `AuthenticationRequiredError` | Authenticated route called with no password set (raised before any traffic) |
309
+ | `AuthenticationError` | The device rejected the credentials (HTTP 401; `.reason`) |
310
+ | `RateLimitedError` | Too many outstanding auth challenges (HTTP 429 on the challenge route); retry after a short sleep |
311
+ | `CooldownError` | Route locked or in cooldown (HTTP 409/429; `.retry_after` hint in seconds) |
312
+ | `VectorServerError` | The device handler raised an error (HTTP 5xx; `.status`) |
313
+ | `UnsupportedFirmwareError` | The route doesn't exist on this firmware (HTTP 404); an update may be required |
314
+
315
+ ## Development
316
+
317
+ ```bash
318
+ git clone https://github.com/warped-pinball/python-library
319
+ cd python-library
320
+ pip install -e ".[dev,usb]"
321
+
322
+ pytest # run the tests
323
+ ruff check . # lint
324
+ ```