oep-client-python 0.0.24__tar.gz → 0.0.25__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 (54) hide show
  1. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/CHANGELOG.md +4 -0
  2. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/PKG-INFO +5 -2
  3. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/README.ja.md +4 -2
  4. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/README.md +4 -1
  5. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/pyproject.toml +1 -1
  6. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/__init__.py +1 -1
  7. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/endpoint.py +6 -4
  8. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/link.py +69 -8
  9. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/registry.py +2 -2
  10. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_port_speed.py +76 -1
  11. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/.gitignore +0 -0
  12. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/LICENSE +0 -0
  13. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/__main__.py +0 -0
  14. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/arm.py +0 -0
  15. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/capture.py +0 -0
  16. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/catalog.py +0 -0
  17. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/ch32_flash.py +0 -0
  18. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/cobs.py +0 -0
  19. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/config.py +0 -0
  20. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/console.py +0 -0
  21. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/core.py +0 -0
  22. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/decode.py +0 -0
  23. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/dump.py +0 -0
  24. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/fake.py +0 -0
  25. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/fake_capture.py +0 -0
  26. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/fake_serial.py +0 -0
  27. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/fake_serve.py +0 -0
  28. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/fixture.py +0 -0
  29. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/frames.py +0 -0
  30. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/hid_stream.py +0 -0
  31. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/host.py +0 -0
  32. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/interfaces.py +0 -0
  33. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/message.py +0 -0
  34. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/names.py +0 -0
  35. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/riscv.py +0 -0
  36. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/rp2350.py +0 -0
  37. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/uiapduino.py +0 -0
  38. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/src/oep_client/usb_stream.py +0 -0
  39. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_capabilities.py +0 -0
  40. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_capture.py +0 -0
  41. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_cobs.py +0 -0
  42. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_config.py +0 -0
  43. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_config_disable.py +0 -0
  44. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_fake_capture.py +0 -0
  45. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_fake_spec.py +0 -0
  46. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_fixture_targets.py +0 -0
  47. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_flash_console.py +0 -0
  48. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_hid_stream.py +0 -0
  49. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_interfaces.py +0 -0
  50. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_link_host.py +0 -0
  51. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_link_serial.py +0 -0
  52. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_session.py +0 -0
  53. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_target_parts.py +0 -0
  54. {oep_client_python-0.0.24 → oep_client_python-0.0.25}/tests/test_usb_stream.py +0 -0
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.25
6
+ - (EN) port_speed's idle limit (oep-spec 877cf01, core §3.5 items 5 and 6; registry synced, `port_speed_idle_max_ms` = 3000): a committed rate goes back after at most 3 s with no good frame, so a host that raised the speed and died leaves it no longer than that. The fake clamps idle_ms the same way (0 and anything longer: the maximum; 0 used to mean never). `raise_speed`'s `idle_ms` defaults to the maximum (0 or more than it: the maximum). While raised, the link sends a keepalive before a request (or a pipelined exchange, or inside a long `pump`) when it has been quiet for 1 s; `SerialLink.keep_alive()` does the same for a caller that sits idle for long (no background thread). `open_host` on a serial port (COBS) retries its first confirm for about 4 s (`OPEN_RETRY_S`, the idle maximum and a second; 0.5 s each) to wait out a rate left over, then raises TimeoutError; other transports keep their timeout.
7
+ - (JA) port_speed の黙りの上限(oep-spec 877cf01、core §3.5 の 5 と 6。registry を写し直した。`port_speed_idle_max_ms` = 3000): 決めた速さは正しいフレームが来ないまま最長 3 秒で戻るので、速さを上げた host が落ちてもそれより長くは残らない。fake も idle_ms を同じに切り詰める(0 とそれより長い値は最長。0 はこれまで戻らないだった)。`raise_speed` の `idle_ms` の既定は最長(0 と最長より長い値も最長)。上げている間、link は 1 秒黙っていれば要求の前(並べた送り、長い `pump` の中も)に keepalive を送る。長く黙る呼び出し側は `SerialLink.keep_alive()` で同じことをする(裏のスレッドは無い)。シリアルの口(COBS)の `open_host` は最初の confirm を約 4 秒(`OPEN_RETRY_S`、黙りの最長と 1 秒。1 回 0.5 秒)繰り返して残った速さが戻るのを待ち、それでも答えが無ければ TimeoutError。ほかの経路の時間切れは変わらない。
8
+
5
9
  ## 0.0.24
6
10
  - (EN) A serial link whose port a session holds sends a request again as soon as a broken frame arrives, instead of after the timeout: the probe sends no raw bytes on a held port (oep-core §3.4), so the broken frame was the reply, and the repeat is answered from the probe's retry table. A 60 KB logic capture read on an M5Stack ATOM went from 4.0 to 8.3 KB/s at 115200 and runs at 76 KB/s at 1.5 Mbaud (dogfooding port_speed, 2026-10-01).
7
11
  - (JA) セッションが口を持っているシリアルの経路では、壊れたフレームが来たら時間切れを待たずにすぐ送り直す: 持たれている口には probe が生のバイトを送らない(oep-core §3.4)ので、壊れたフレームは応答で、送り直しには probe の送り直しの表が答える。M5Stack ATOM で 60 KB のロジックのキャプチャの読み出しが 115200 で 4.0 → 8.3 KB/s、1.5 Mbaud で 76 KB/s になった(port_speed のドッグフーディング、2026-10-01)。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oep-client-python
3
- Version: 0.0.24
3
+ Version: 0.0.25
4
4
  Summary: Open Embedded Probe (OEP) v1 host: serial / USB / TCP transports, the session rules, the standard interfaces, and a fake probe
5
5
  Project-URL: Homepage, https://github.com/Open-Embedded-Probe/oep-client-python
6
6
  Project-URL: Repository, https://github.com/Open-Embedded-Probe/oep-client-python
@@ -129,7 +129,10 @@ but not 1500000 towards the host: oep-spec docs/uart-speed-negotiation.ja.md), s
129
129
  (`link.speed`: `rate`, `chosen`, `in_kb_s` / `out_kb_s`, `trials`) is there to budget a capture or a write.
130
130
 
131
131
  The probe goes back to the boot speed by itself when the session ends (`end`, a lapse, a force), when frames break or the
132
- line goes quiet (`idle_ms`): the link follows an `end` at once, and a request that goes unanswered at a raised rate (its
132
+ line goes quiet (`idle_ms`, at most `port_speed_idle_max_ms` = 3 s; a host that died leaves the rate no longer than
133
+ that): while raised the link sends a keepalive before a request when it has been quiet for 1 s, and `hst.link.keep_alive()`
134
+ does the same for a caller that sits idle for long; opening a serial port retries its first confirm for about 4 s to
135
+ wait out a rate left over. The link follows an `end` at once, and a request that goes unanswered at a raised rate (its
133
136
  resend too) takes the link back to the boot speed, confirmed, and goes once more there - it never wedges. A probe
134
137
  without the feature answers `not supported` and stays at its speed. Behind a broker (TCP) the broker does this, not the
135
138
  client. Serial ports are also opened in the driver's low-latency mode where it has one (an FTDI's latency timer 16 -> 1 ms
@@ -100,8 +100,10 @@ print(hst.link.speed.to_text()) # 試した速さごとに、実際
100
100
  3 MHz ÷ n だけ、CH340 は 921600 は通り 1500000 は probe → host が壊れた: oep-spec docs/uart-speed-negotiation.ja.md)ので、速さは
101
101
  host が選ぶ。結果(`link.speed`: `rate`、`chosen`、`in_kb_s` / `out_kb_s`、`trials`)はキャプチャや書き込みの予算を立てるのに使う。
102
102
 
103
- probe はセッションが終わったとき(`end`、lease の期限切れ、force)、フレームが壊れたとき、線が黙ったとき(`idle_ms`)に自分で
104
- 起動時の速さに戻る: link は `end` にはすぐ合わせ、上げた速さで応答の来ない要求(送り直しも)があれば、起動時の速さに戻って
103
+ probe はセッションが終わったとき(`end`、lease の期限切れ、force)、フレームが壊れたとき、線が黙ったとき(`idle_ms`、最長
104
+ `port_speed_idle_max_ms` = 3 秒。落ちた host の速さもそれより長くは残らない)に自分で起動時の速さに戻る: 上げている間、link は
105
+ 1 秒黙っていれば要求の前に keepalive を送り、長く黙る呼び出し側は `hst.link.keep_alive()` で同じことをする。シリアルの口を開くと
106
+ 最初の confirm を約 4 秒繰り返して、残った速さが戻るのを待つ。link は `end` にはすぐ合わせ、上げた速さで応答の来ない要求(送り直しも)があれば、起動時の速さに戻って
105
107
  confirm し、そこでもう一度送る(固まらない)。機能の無い probe は `not supported` で、速さは変わらない。ブローカー(TCP)の後ろでは
106
108
  client ではなくブローカーが行う。シリアルの口は、ドライバにあれば low-latency のモードで開く(FTDI の latency timer 16 → 1 ms で
107
109
  UART bridge の速度が 3 倍になった)。ボードの起動時の速さが 115200 でなければ `open_host(..., baud=)` で渡す。
@@ -107,7 +107,10 @@ but not 1500000 towards the host: oep-spec docs/uart-speed-negotiation.ja.md), s
107
107
  (`link.speed`: `rate`, `chosen`, `in_kb_s` / `out_kb_s`, `trials`) is there to budget a capture or a write.
108
108
 
109
109
  The probe goes back to the boot speed by itself when the session ends (`end`, a lapse, a force), when frames break or the
110
- line goes quiet (`idle_ms`): the link follows an `end` at once, and a request that goes unanswered at a raised rate (its
110
+ line goes quiet (`idle_ms`, at most `port_speed_idle_max_ms` = 3 s; a host that died leaves the rate no longer than
111
+ that): while raised the link sends a keepalive before a request when it has been quiet for 1 s, and `hst.link.keep_alive()`
112
+ does the same for a caller that sits idle for long; opening a serial port retries its first confirm for about 4 s to
113
+ wait out a rate left over. The link follows an `end` at once, and a request that goes unanswered at a raised rate (its
111
114
  resend too) takes the link back to the boot speed, confirmed, and goes once more there - it never wedges. A probe
112
115
  without the feature answers `not supported` and stays at its speed. Behind a broker (TCP) the broker does this, not the
113
116
  client. Serial ports are also opened in the driver's low-latency mode where it has one (an FTDI's latency timer 16 -> 1 ms
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "oep-client-python"
7
- version = "0.0.24"
7
+ version = "0.0.25"
8
8
  description = "Open Embedded Probe (OEP) v1 host: serial / USB / TCP transports, the session rules, the standard interfaces, and a fake probe"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.24"
5
+ __version__ = "0.0.25"
@@ -36,7 +36,8 @@ define:
36
36
  - port_speed (core §3.5, optional): on when the profile's describe declares it (`port_speed_base`, the boot speed;
37
37
  None = off: the op is unknown_operation). try / commit / revert on the UART bridge the request came in on (else
38
38
  unavailable cause 6), a rate the fake UART cannot make (outside 300..5000000) unsupported; a try reverts after
39
- verify_ms without a commit or at a broken candidate, a commit after idle_ms with no good frame or 3 broken
39
+ verify_ms without a commit or at a broken candidate, a commit after idle_ms (at most port_speed_idle_max_ms, 3000;
40
+ 0 and anything longer count as that maximum) with no good frame or 3 broken
40
41
  candidates within 1 s, the session's end reverts after its answer. The line itself is modelled by `broken_rates`
41
42
  (rate -> BrokenRate): frames at such a rate break, from a size and in the directions given (`fake_serial` applies it).
42
43
 
@@ -321,6 +322,7 @@ class BrokenRate:
321
322
  PORT_SPEED_TAG = reg.CORE.tlv["describe"]["port_speed"]
322
323
  OP_PORT_SPEED = reg.CORE.op["port_speed"]
323
324
  SPEED_STEP = reg.CORE.enum["port_speed_step"]
325
+ SPEED_IDLE_MAX_MS = reg.TIMING["port_speed_idle_max_ms"] # committed: idle_ms at most this (0 and longer: this)
324
326
  SPEED_BAD_MAX, SPEED_BAD_WINDOW_MS = 3, 1000 # committed: this many broken candidates within the window revert
325
327
  SPEED_RATES = (300, 5_000_000) # what the fake's UART makes (anything between, exactly)
326
328
 
@@ -472,7 +474,7 @@ class Endpoint:
472
474
  self.speed_rate = 0 # the rate it runs at
473
475
  self.speed_asked = 0 # the baud the try asked (the commit names it again)
474
476
  self.speed_until_ms = 0 # try: the commit's deadline (verify_ms)
475
- self.speed_idle_ms = 0 # committed: revert after this long with no good frame (0 never)
477
+ self.speed_idle_ms = 0 # committed: revert after this long with no good frame
476
478
  self.speed_good_ms = 0 # the last good frame on that port
477
479
  self.speed_bad: list[int] = [] # committed: the times of the recent broken candidates
478
480
  self.speed_pending: tuple | None = None # ("switch", port, baud, verify_ms) / ("revert",): after the answer
@@ -1203,7 +1205,7 @@ class Endpoint:
1203
1205
  if self.speed_state == "base" or port != self.speed_port or baud != self.speed_asked:
1204
1206
  raise unavailable("wrong_state") # nothing tried, or another baud
1205
1207
  self.speed_state = "committed"
1206
- self.speed_idle_ms = idle_ms
1208
+ self.speed_idle_ms = idle_ms if 0 < idle_ms <= SPEED_IDLE_MAX_MS else SPEED_IDLE_MAX_MS
1207
1209
  self.speed_good_ms = self.now()
1208
1210
  self.speed_bad.clear()
1209
1211
  return self._answer(struct.pack("<I", self.speed_rate), ignored)
@@ -1264,7 +1266,7 @@ class Endpoint:
1264
1266
  now = self.now()
1265
1267
  if self.speed_state == "try" and now >= self.speed_until_ms:
1266
1268
  self._speed_revert() # no commit within verify_ms
1267
- elif self.speed_state == "committed" and self.speed_idle_ms and now - self.speed_good_ms >= self.speed_idle_ms:
1269
+ elif self.speed_state == "committed" and now - self.speed_good_ms >= self.speed_idle_ms:
1268
1270
  self._speed_revert()
1269
1271
 
1270
1272
  def _page(self, tlvs: list[bytes], first: int) -> bytes:
@@ -26,6 +26,10 @@ transfer both ways (link_source / link_sink with max_frame-sized frames) that co
26
26
  throughput, then commit it, or revert and wait the probe out and re-confirm at the boot speed. The report stays on the
27
27
  link (`link.speed`). Once raised, a request whose answer never comes even after its resend sends the link back to the
28
28
  boot speed (the probe reverts by itself) and goes once more there: the link never wedges at a rate the probe left.
29
+ The probe also goes back after port_speed_idle_max_ms (3 s) with no good frame, so while raised the link sends a
30
+ keepalive before a request when it has been quiet for 1 s, and `keep_alive()` does the same for a caller that sits
31
+ idle for long. A host opening a serial port retries its first confirm for that maximum and a little (4 s in all): a
32
+ host that raised the speed and died leaves the probe at its rate until then.
29
33
  """
30
34
 
31
35
  from __future__ import annotations
@@ -55,6 +59,10 @@ class PortBusy(OSError):
55
59
 
56
60
 
57
61
  BASE_BAUD = 115200 # the boot speed of every reference UART bridge (the board's profile decides; core §3.5)
62
+ IDLE_MAX_MS = reg.TIMING["port_speed_idle_max_ms"] # a committed rate goes back after this with no good frame (§3.5)
63
+ KEEPALIVE_S = 1.0 # raised: a keepalive once the link has been quiet this long (well inside IDLE_MAX_MS)
64
+ OPEN_RETRY_S = IDLE_MAX_MS / 1000 + 1.0 # opening a serial port: confirm retried this long (a raised rate left over)
65
+ OPEN_TRY_S = 0.5 # each of those confirms waits this long (at most the link's timeout)
58
66
 
59
67
 
60
68
  def open_serial(port: str, baud: int = BASE_BAUD):
@@ -158,6 +166,8 @@ class SerialLink:
158
166
  self.speed_lost = 0 # times a raised rate was found gone (back to the boot speed)
159
167
  self.fallback = True # a raised rate that stops answering: back to the boot speed
160
168
  self.corr_source = self._own_corr # the host's correlation counter once bound (open_host)
169
+ self.keepalive_frame = None # raised: a keepalive request in the session, once bound (attach_host)
170
+ self.last_tx = time.monotonic() # when the link last wrote (raised: quiet for KEEPALIVE_S = keepalive)
161
171
  self.held = lambda: False # a session holds the port (its raw transfer stopped): broken = resend
162
172
  self.blind = lambda: [] # the host's blind stops (unsubscribe / end) once bound
163
173
  self._corr_n = 0x8000
@@ -178,6 +188,7 @@ class SerialLink:
178
188
  self.frames.send_many(messages)
179
189
  else:
180
190
  self.stream.write(b"".join(cobs.frame(msg) for msg in messages))
191
+ self.last_tx = time.monotonic()
181
192
 
182
193
  def _recv(self) -> bytes:
183
194
  if self.framing == "length":
@@ -250,10 +261,17 @@ class SerialLink:
250
261
  n = 0
251
262
  try:
252
263
  while True:
264
+ if self._raised(): # a long pump at a raised rate keeps the line alive (core §3.5)
265
+ self.timeout = saved
266
+ self._keep_raised()
253
267
  self.timeout = max(0.0, deadline - time.monotonic())
268
+ if self._raised():
269
+ self.timeout = min(self.timeout, KEEPALIVE_S)
254
270
  try:
255
271
  frame = self._recv()
256
272
  except TimeoutError:
273
+ if self._raised() and time.monotonic() < deadline:
274
+ continue
257
275
  return n
258
276
  except FramingLost:
259
277
  self.timeout = saved
@@ -307,6 +325,7 @@ class SerialLink:
307
325
  self.resync()
308
326
 
309
327
  def send(self, message: bytes) -> bytes:
328
+ self._keep_raised()
310
329
  try:
311
330
  reply = self._send(message)
312
331
  except TimeoutError:
@@ -355,6 +374,7 @@ class SerialLink:
355
374
  answered go once more with the same corr (oep-core §5.2: the probe answers a repeat from what it kept); a second
356
375
  failure is raised.
357
376
  """
377
+ self._keep_raised()
358
378
  replies: list[bytes] = []
359
379
  try:
360
380
  return self._exchange_once(messages, max_inflight, window_bytes, replies)
@@ -397,6 +417,15 @@ class SerialLink:
397
417
  finally:
398
418
  self.timeout = saved
399
419
 
420
+ def _confirm_within(self, wait_s: float, each_s: float) -> bool:
421
+ """Confirms, each waiting `each_s`, until one is answered (True) or `wait_s` has passed (False)."""
422
+ deadline = time.monotonic() + wait_s
423
+ while True:
424
+ if self.confirm_raw(each_s):
425
+ return True
426
+ if time.monotonic() >= deadline:
427
+ return False
428
+
400
429
  def back_to_base(self, wait_s: float = 3.0) -> bool:
401
430
  """The host at the boot speed again, and the probe confirmed there: confirms every 0.25 s up to `wait_s` (a
402
431
  probe still trying waits out its verify_ms; one committed reverts at the broken candidates these make)."""
@@ -404,12 +433,31 @@ class SerialLink:
404
433
  if self.base_baud is None:
405
434
  return False
406
435
  self.set_baud(self.base_baud)
407
- deadline = time.monotonic() + wait_s
408
- while True:
409
- if self.confirm_raw(0.25):
410
- return True
411
- if time.monotonic() >= deadline:
412
- return False
436
+ return self._confirm_within(wait_s, 0.25)
437
+
438
+ def _raised(self) -> bool:
439
+ return self.base_baud is not None and self.baud != self.base_baud
440
+
441
+ def keep_alive(self) -> bool:
442
+ """While a raised rate is in force and a session holds the port: a keepalive when the link has been quiet for
443
+ KEEPALIVE_S (1 s). The probe goes back to the boot speed after port_speed_idle_max_ms (3 s) with no good frame
444
+ (core §3.5); every request already does this before it goes out, so only a caller that sits idle for long
445
+ (waiting on a person, a sleep between requests) calls it - often is fine, it sends nothing otherwise.
446
+ True when a keepalive went out."""
447
+ if not self._raised() or self.keepalive_frame is None or not self.held():
448
+ return False
449
+ if time.monotonic() - self.last_tx < KEEPALIVE_S:
450
+ return False
451
+ self.last_tx = time.monotonic() # before sending: send() asks again and must not recurse
452
+ self.send(self.keepalive_frame())
453
+ return True
454
+
455
+ def _keep_raised(self) -> None:
456
+ """keep_alive before a request; a keepalive that fails is left to the request itself to find out."""
457
+ try:
458
+ self.keep_alive()
459
+ except (TimeoutError, cobs.CorruptFrame, FramingLost):
460
+ pass
413
461
 
414
462
  def _speed_fallback(self) -> bool:
415
463
  """A request went unanswered (its resend too) while the link ran above the boot speed: the probe went back
@@ -461,12 +509,23 @@ class SerialLink:
461
509
  self.corr_source = hst.next_corr
462
510
  self.blind = hst.blind_stop
463
511
  self.held = lambda: hst.session is not None
512
+ self.keepalive_frame = lambda: m.Request(hst.next_corr(), m.CORE_FN, m.OP_KEEPALIVE, b"", hst.session).pack()
464
513
  hst.link = self
514
+ if self.framing == "cobs" and getattr(self, "transport", None) == "serial":
515
+ self.wait_boot_speed()
465
516
  limits = hst.confirm()
466
517
  hst.exchange = self.bind(limits)
467
518
  if self.framing == "length" and limits.get("max_frame"):
468
519
  self.frames.max_frame = limits["max_frame"]
469
520
 
521
+ def wait_boot_speed(self, wait_s: float | None = None) -> None:
522
+ """A serial port just opened: confirm at the boot speed, retried for OPEN_RETRY_S (port_speed_idle_max_ms and
523
+ a second; at least the link's timeout) - a host that raised the speed and died leaves the probe at that rate
524
+ until its idle limit runs out (core §3.5 item 6). TimeoutError when none was answered."""
525
+ wait_s = max(OPEN_RETRY_S, self.timeout) if wait_s is None else wait_s
526
+ if not self._confirm_within(wait_s, min(self.timeout, OPEN_TRY_S)):
527
+ raise TimeoutError(f"no answer to confirm at {self.baud} for {wait_s:.1f} s")
528
+
470
529
  def close(self) -> None:
471
530
  self.stream.close()
472
531
 
@@ -736,7 +795,7 @@ SWITCH_SETTLE_S = 0.02 # after a baud change, before the first byte at the new
736
795
 
737
796
 
738
797
  def raise_speed(hst, rates: list[int], *, verify_bytes: int = 32768, verify_s: float = 1.0,
739
- verify_ms: int | None = None, idle_ms: int = 0, port: int | None = None) -> SpeedReport:
798
+ verify_ms: int | None = None, idle_ms: int = IDLE_MAX_MS, port: int | None = None) -> SpeedReport:
740
799
  """port_speed (oep-core §3.5), opt-in: try `rates` in order on the UART bridge this host opened, and commit the
741
800
  first that passes. Each: try (answered at the speed now) -> the host switches -> verify both ways with
742
801
  max_frame-sized frames for verify_bytes or verify_s in all (broken frames counted, KB/s measured each way) ->
@@ -745,7 +804,8 @@ def raise_speed(hst, rates: list[int], *, verify_bytes: int = 32768, verify_s: f
745
804
  probe's UART cannot make is skipped (unsupported). The session must be open (the rate lasts as long as it does).
746
805
 
747
806
  verify_ms: how long the probe waits for the commit (default: verify_s + 1.5 s, at most 65535). idle_ms: once
748
- committed, the probe reverts after this long with no good frame (0: never; it reverts at the session's end anyway).
807
+ committed, the probe reverts after this long with no good frame (default and at most port_speed_idle_max_ms,
808
+ 3000; 0 and anything longer mean that maximum). The link keeps the line alive meanwhile (`keep_alive`).
749
809
  port: the transport index (default: the probe's first UART bridge). -> the report, also kept as `hst.link.speed`."""
750
810
  lk = getattr(hst, "link", None)
751
811
  base = getattr(lk, "base_baud", None)
@@ -766,6 +826,7 @@ def raise_speed(hst, rates: list[int], *, verify_bytes: int = 32768, verify_s: f
766
826
  report.supported = True
767
827
  wait = (verify_ms if verify_ms is not None else min(65535, int(verify_s * 1000) + 1500))
768
828
  lk.fallback = False # every failure here is handled here
829
+ idle_ms = idle_ms if 0 < idle_ms <= IDLE_MAX_MS else IDLE_MAX_MS
769
830
  try:
770
831
  return _raise(hst, lk, report, rates, port, base, wait, verify_bytes, verify_s, idle_ms)
771
832
  finally:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from types import SimpleNamespace as _NS
4
4
 
5
- REGISTRY_HASH = "1043a67bfcb96a43"
5
+ REGISTRY_HASH = "f98b4992f5b8ebe7"
6
6
  SCHEMA = 1
7
7
  PROTOCOL_REVISION = 1
8
8
  CONFIRM_REQUEST_MAGIC = 'OEP?'
@@ -20,7 +20,7 @@ OUTCOMES = {"success": 0x00, "failed": 0x01, "partial": 0x02}
20
20
  REJECT_REASONS = {"unknown_function": 0x01, "unknown_operation": 0x02, "malformed": 0x03, "unavailable": 0x04, "busy": 0x05, "window_exceeded": 0x06, "no_session": 0x07, "locked": 0x08, "session_required": 0x09, "no_connection": 0x0A, "unsupported": 0x0B, "result_lost": 0x0C, "corr_reused": 0x0D, "expired": 0x0E}
21
21
  STATUS = {"ok": 0x00, "wait": 0x01, "line": 0x02, "fault": 0x03, "timeout": 0x04, "state": 0x05}
22
22
  DESCRIBE_COMMON = {"role_channels": 0x01, "max_clock_hz": 0x02, "max_length": 0x03, "min_clock_hz": 0x05, "features": 0x06, "implementation": 0x07, "channel_group": 0x08}
23
- TIMING = {"resync_quiet_ms": 0x32, "probe_frame_gap_ms": 0xC8, "host_frame_pause_max_ms": 0x64, "heartbeat_default_ms": 0x3E8}
23
+ TIMING = {"resync_quiet_ms": 0x32, "probe_frame_gap_ms": 0xC8, "host_frame_pause_max_ms": 0x64, "heartbeat_default_ms": 0x3E8, "port_speed_idle_max_ms": 0xBB8}
24
24
  USB = {"iproduct_prefix": 'OEP', "vendor_bulk_class": 0xFF, "vendor_bulk_subclass": 0x4F, "vendor_bulk_protocol": 0x45, "hid_usage_page": 0xFF4F, "hid_usage": 0x45, "reference_vid": 0x1209, "reference_pid": 0x4F45}
25
25
  LIMITS = {"lease_min_ms": 0x3E8, "lease_max_ms": 0xEA60, "owner_max_bytes": 0x20, "unit_id_max_bytes": 0x20, "slot_name_max_bytes": 0x20, "interface_name_max_bytes": 0x40, "max_op_ms_reference": 0x2710}
26
26
  COMMON = _NS(enum={"read_from": {"position": 0x00, "oldest": 0x01, "now": 0x02, "last_mark": 0x03}, "read_flags": {"more": 0x01, "gap": 0x02}, "mark_kind": {"reset": 0x01, "restart": 0x02, "attach": 0x03, "detach": 0x04, "lost": 0x05, "clear": 0x06, "host": 0x07, "link_lost": 0x08, "closed": 0x09}, "mark_detail_reset": {"ndmreset": 0x01, "nrst": 0x02, "attach_reset": 0x03}, "mark_detail_restart": {"havereset": 0x01, "resync": 0x02}, "mark_detail_lost": {"overflow": 0x01, "framing": 0x02, "parity": 0x03, "target_timeout": 0x04}, "mark_detail_closed": {"all_released": 0x01, "expired": 0x02, "slot_changed": 0x03, "connection_closed": 0x04}})
@@ -87,8 +87,22 @@ def test_try_then_commit_answered_at_the_old_speed():
87
87
  assert r.detail == m.UNAVAILABLE and r.payload[:3] == bytes([0x01, 1, 6])
88
88
  r = p.send(0, PS, ps(0, 1500000, COMMIT), sid)
89
89
  assert r.succeeded and p.ep.speed_state == "committed"
90
- p.tick(10_000) # idle_ms 0: no idle revert
90
+ p.tick(endpoint.SPEED_IDLE_MAX_MS - 1) # idle_ms 0: the maximum, not never
91
91
  assert p.ep.port_baud(0) == 1500000
92
+ p.tick(1)
93
+ assert p.ep.port_baud(0) == 115200
94
+
95
+
96
+ def test_a_long_idle_ms_is_clamped_to_the_maximum():
97
+ p = Port()
98
+ sid = opened(p)
99
+ p.send(0, PS, ps(0, 500000, TRY), sid)
100
+ assert p.send(0, PS, ps(0, 500000, COMMIT, 0, 600_000), sid).succeeded
101
+ assert p.ep.speed_idle_ms == endpoint.SPEED_IDLE_MAX_MS == 3000
102
+ p.tick(2999)
103
+ assert p.ep.port_baud(0) == 500000
104
+ p.tick(1)
105
+ assert p.ep.port_baud(0) == 115200
92
106
 
93
107
 
94
108
  def test_try_times_out_and_a_late_commit_is_wrong_state():
@@ -235,6 +249,67 @@ def test_a_revert_seen_as_a_timeout_goes_back_to_the_boot_speed():
235
249
  assert lk.baud == 115200 and lk.speed_lost == 1 and lk.speed.lost and lk.speed.rate == 115200
236
250
 
237
251
 
252
+ def test_raise_speed_commits_the_idle_maximum_by_default():
253
+ ep, hst, lk = in_process()
254
+ link.raise_speed(hst, [750000], **FAST)
255
+ assert ep.speed_idle_ms == link.IDLE_MAX_MS == 3000
256
+ link.raise_speed(hst, [500000], idle_ms=0, **FAST) # 0: the maximum too
257
+ assert ep.speed_idle_ms == 3000
258
+
259
+
260
+ def test_a_raised_link_keeps_the_line_alive_when_quiet():
261
+ ep, hst, lk = in_process()
262
+ link.raise_speed(hst, [750000], **FAST)
263
+ sent = []
264
+ frame = lk.keepalive_frame
265
+ lk.keepalive_frame = lambda: sent.append(1) or frame()
266
+ assert not lk.keep_alive() # just spoke: nothing to do
267
+ for _ in range(3): # 3.6 s in all, past the probe's idle limit
268
+ time.sleep(1.2)
269
+ assert lk.keep_alive()
270
+ ep.tick()
271
+ assert ep.port_baud(0) == 750000 and len(sent) == 3
272
+ time.sleep(1.2)
273
+ hst.request(0, m.OP_LOCK_STATE, locked=False) # a request after 1 s of quiet: a keepalive first
274
+ assert len(sent) == 4 and lk.baud == 750000 and lk.speed_lost == 0
275
+ hst.end()
276
+ time.sleep(1.2)
277
+ assert not lk.keep_alive() # back at the boot speed: none
278
+
279
+
280
+ def second_host(ep):
281
+ lk = link.SerialLink.on_stream(fake_serial.FakeSerialStream(ep, 0), "cobs", 0.5)
282
+ lk.transport = "serial"
283
+ hst = h.Host(lk.send)
284
+ t0 = time.monotonic()
285
+ lk.attach_host(hst)
286
+ return hst, lk, time.monotonic() - t0
287
+
288
+
289
+ def test_open_waits_out_a_raised_rate_a_host_that_died_left():
290
+ ep, hst, lk = in_process()
291
+ link.raise_speed(hst, [750000], **FAST) # this host dies here, its lease long
292
+ hst2, lk2, took = second_host(ep) # the confirms at 115200 are broken candidates: reverts
293
+ assert ep.port_baud(0) == 115200 and lk2.baud == 115200 and took < link.OPEN_RETRY_S
294
+ hst2.confirm()
295
+
296
+
297
+ def test_open_waits_for_the_idle_limit_when_broken_candidates_do_not_revert():
298
+ ep, hst, lk = in_process()
299
+ link.raise_speed(hst, [750000], **FAST)
300
+ heard = ep.speed_frame
301
+ ep.speed_frame = lambda port, good: heard(port, good) if good else None # only the idle limit takes it back
302
+ hst2, lk2, took = second_host(ep)
303
+ assert ep.port_baud(0) == 115200 and 2.5 < took < link.OPEN_RETRY_S + 1.5
304
+ ep2, hst3, lk3 = in_process()
305
+ link.raise_speed(hst3, [750000], **FAST)
306
+ ep2._speed_revert = lambda: None # never comes back: the open gives up
307
+ lk4 = link.SerialLink.on_stream(fake_serial.FakeSerialStream(ep2, 0), "cobs", 0.5)
308
+ lk4.corr_source = lambda: 7
309
+ with pytest.raises(TimeoutError):
310
+ lk4.wait_boot_speed(0.6)
311
+
312
+
238
313
  def test_an_off_probe_is_not_supported_and_stays_at_the_boot_speed(monkeypatch):
239
314
  ep, hst, lk = in_process()
240
315
  ep.port_speed_base = None