Barmaja-PyArduino 2.0.0__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.
Files changed (87) hide show
  1. Barmaja_PyArduino/BARMAJA_PyArduino.ico +0 -0
  2. Barmaja_PyArduino/__init__.py +528 -0
  3. Barmaja_PyArduino/__init__.pyi +331 -0
  4. Barmaja_PyArduino/__main__.py +2 -0
  5. Barmaja_PyArduino/adapters/__init__.py +35 -0
  6. Barmaja_PyArduino/api_registry.py +459 -0
  7. Barmaja_PyArduino/backend/__init__.py +3 -0
  8. Barmaja_PyArduino/backend/uno_cpp.py +1680 -0
  9. Barmaja_PyArduino/board.py +80 -0
  10. Barmaja_PyArduino/cli.py +223 -0
  11. Barmaja_PyArduino/compiler.py +1721 -0
  12. Barmaja_PyArduino/console.py +217 -0
  13. Barmaja_PyArduino/device_packs.py +367 -0
  14. Barmaja_PyArduino/device_packs_builtin/Button/arduino/Barmaja_Button/library.properties +9 -0
  15. Barmaja_PyArduino/device_packs_builtin/Button/arduino/Barmaja_Button/src/Barmaja_Button.h +18 -0
  16. Barmaja_PyArduino/device_packs_builtin/Button/example.py +10 -0
  17. Barmaja_PyArduino/device_packs_builtin/Button/pack.json +40 -0
  18. Barmaja_PyArduino/device_packs_builtin/ClockDS3231/arduino/Barmaja_ClockDS3231/library.properties +9 -0
  19. Barmaja_PyArduino/device_packs_builtin/ClockDS3231/arduino/Barmaja_ClockDS3231/src/Barmaja_ClockDS3231.h +20 -0
  20. Barmaja_PyArduino/device_packs_builtin/ClockDS3231/example.py +8 -0
  21. Barmaja_PyArduino/device_packs_builtin/ClockDS3231/pack.json +136 -0
  22. Barmaja_PyArduino/device_packs_builtin/DistanceVL53L0X/arduino/Barmaja_DistanceVL53L0X/library.properties +9 -0
  23. Barmaja_PyArduino/device_packs_builtin/DistanceVL53L0X/arduino/Barmaja_DistanceVL53L0X/src/Barmaja_DistanceVL53L0X.h +12 -0
  24. Barmaja_PyArduino/device_packs_builtin/DistanceVL53L0X/example.py +8 -0
  25. Barmaja_PyArduino/device_packs_builtin/DistanceVL53L0X/pack.json +48 -0
  26. Barmaja_PyArduino/device_packs_builtin/LCDParallel/arduino/Barmaja_LCDParallel/library.properties +9 -0
  27. Barmaja_PyArduino/device_packs_builtin/LCDParallel/arduino/Barmaja_LCDParallel/src/Barmaja_LCDParallel.h +17 -0
  28. Barmaja_PyArduino/device_packs_builtin/LCDParallel/example.py +10 -0
  29. Barmaja_PyArduino/device_packs_builtin/LCDParallel/pack.json +137 -0
  30. Barmaja_PyArduino/device_packs_builtin/LoadCellHX711/arduino/Barmaja_LoadCellHX711/library.properties +9 -0
  31. Barmaja_PyArduino/device_packs_builtin/LoadCellHX711/arduino/Barmaja_LoadCellHX711/src/Barmaja_LoadCellHX711.h +16 -0
  32. Barmaja_PyArduino/device_packs_builtin/LoadCellHX711/example.py +11 -0
  33. Barmaja_PyArduino/device_packs_builtin/LoadCellHX711/pack.json +74 -0
  34. Barmaja_PyArduino/device_packs_builtin/NeoPixelStrip/arduino/Barmaja_NeoPixelStrip/library.properties +9 -0
  35. Barmaja_PyArduino/device_packs_builtin/NeoPixelStrip/arduino/Barmaja_NeoPixelStrip/src/Barmaja_NeoPixelStrip.h +15 -0
  36. Barmaja_PyArduino/device_packs_builtin/NeoPixelStrip/example.py +13 -0
  37. Barmaja_PyArduino/device_packs_builtin/NeoPixelStrip/pack.json +114 -0
  38. Barmaja_PyArduino/device_packs_builtin/OLEDText/arduino/Barmaja_OLEDText/library.properties +9 -0
  39. Barmaja_PyArduino/device_packs_builtin/OLEDText/arduino/Barmaja_OLEDText/src/Barmaja_OLEDText.h +21 -0
  40. Barmaja_PyArduino/device_packs_builtin/OLEDText/example.py +9 -0
  41. Barmaja_PyArduino/device_packs_builtin/OLEDText/pack.json +103 -0
  42. Barmaja_PyArduino/device_packs_builtin/PressureBMP280/arduino/Barmaja_PressureBMP280/library.properties +9 -0
  43. Barmaja_PyArduino/device_packs_builtin/PressureBMP280/arduino/Barmaja_PressureBMP280/src/Barmaja_PressureBMP280.h +16 -0
  44. Barmaja_PyArduino/device_packs_builtin/PressureBMP280/example.py +8 -0
  45. Barmaja_PyArduino/device_packs_builtin/PressureBMP280/pack.json +92 -0
  46. Barmaja_PyArduino/device_packs_builtin/RotaryEncoder/arduino/Barmaja_RotaryEncoder/library.properties +9 -0
  47. Barmaja_PyArduino/device_packs_builtin/RotaryEncoder/arduino/Barmaja_RotaryEncoder/src/Barmaja_RotaryEncoder.h +18 -0
  48. Barmaja_PyArduino/device_packs_builtin/RotaryEncoder/example.py +10 -0
  49. Barmaja_PyArduino/device_packs_builtin/RotaryEncoder/pack.json +44 -0
  50. Barmaja_PyArduino/device_packs_builtin/ServoDriverPCA9685/arduino/Barmaja_ServoDriverPCA9685/library.properties +9 -0
  51. Barmaja_PyArduino/device_packs_builtin/ServoDriverPCA9685/arduino/Barmaja_ServoDriverPCA9685/src/Barmaja_ServoDriverPCA9685.h +15 -0
  52. Barmaja_PyArduino/device_packs_builtin/ServoDriverPCA9685/example.py +10 -0
  53. Barmaja_PyArduino/device_packs_builtin/ServoDriverPCA9685/pack.json +117 -0
  54. Barmaja_PyArduino/device_packs_builtin/ShiftRegister595/arduino/Barmaja_ShiftRegister595/library.properties +9 -0
  55. Barmaja_PyArduino/device_packs_builtin/ShiftRegister595/arduino/Barmaja_ShiftRegister595/src/Barmaja_ShiftRegister595.h +14 -0
  56. Barmaja_PyArduino/device_packs_builtin/ShiftRegister595/example.py +9 -0
  57. Barmaja_PyArduino/device_packs_builtin/ShiftRegister595/pack.json +83 -0
  58. Barmaja_PyArduino/device_packs_builtin/TemperatureDS18B20/arduino/Barmaja_TemperatureDS18B20/library.properties +9 -0
  59. Barmaja_PyArduino/device_packs_builtin/TemperatureDS18B20/arduino/Barmaja_TemperatureDS18B20/src/Barmaja_TemperatureDS18B20.h +16 -0
  60. Barmaja_PyArduino/device_packs_builtin/TemperatureDS18B20/example.py +8 -0
  61. Barmaja_PyArduino/device_packs_builtin/TemperatureDS18B20/pack.json +63 -0
  62. Barmaja_PyArduino/doctor.py +406 -0
  63. Barmaja_PyArduino/environment.py +595 -0
  64. Barmaja_PyArduino/errors.py +166 -0
  65. Barmaja_PyArduino/flow.py +754 -0
  66. Barmaja_PyArduino/frontend.py +611 -0
  67. Barmaja_PyArduino/installer.py +151 -0
  68. Barmaja_PyArduino/ir.py +660 -0
  69. Barmaja_PyArduino/library_adder.py +440 -0
  70. Barmaja_PyArduino/live.py +859 -0
  71. Barmaja_PyArduino/lowering.py +596 -0
  72. Barmaja_PyArduino/numeric.py +748 -0
  73. Barmaja_PyArduino/pack_builder.py +544 -0
  74. Barmaja_PyArduino/py.typed +0 -0
  75. Barmaja_PyArduino/serial_monitor.py +172 -0
  76. Barmaja_PyArduino/source.py +184 -0
  77. Barmaja_PyArduino/stubgen.py +148 -0
  78. Barmaja_PyArduino/symbols.py +646 -0
  79. Barmaja_PyArduino/target.py +1631 -0
  80. Barmaja_PyArduino/toolchain.py +506 -0
  81. Barmaja_PyArduino/typesys.py +917 -0
  82. Barmaja_PyArduino/workflow.py +213 -0
  83. barmaja_pyarduino-2.0.0.dist-info/METADATA +39 -0
  84. barmaja_pyarduino-2.0.0.dist-info/RECORD +87 -0
  85. barmaja_pyarduino-2.0.0.dist-info/WHEEL +5 -0
  86. barmaja_pyarduino-2.0.0.dist-info/licenses/LICENSE.txt +7 -0
  87. barmaja_pyarduino-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,859 @@
1
+ """Barmaja PyArduino LiveRun support.
2
+
3
+ LiveRun keeps the student's Arduino-style API but executes the Python program on
4
+ its host (normal Python or PictoBlox) while Telemetrix4Arduino runs on the Uno.
5
+
6
+ The public entry point is ``LiveRun()``. The rest of this module is deliberately
7
+ internal so the student surface remains small and consistent with standalone
8
+ Barmaja PyArduino programs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import atexit
14
+ import contextlib
15
+ import io
16
+ import os
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+
24
+ class LiveRunError(RuntimeError):
25
+ """Raised when Barmaja PyArduino cannot start or use LiveRun."""
26
+
27
+
28
+ class LiveStudentError(LiveRunError):
29
+ """Actionable LiveRun setup error; preserve the original cause for debugging."""
30
+
31
+
32
+ class LiveDependencyError(LiveRunError):
33
+ """Required LiveRun Python support is not installed."""
34
+
35
+
36
+ class LiveConnectionError(LiveRunError):
37
+ """The Uno did not answer as a Telemetrix LiveRun board."""
38
+
39
+
40
+ class LivePortBusyError(LiveConnectionError):
41
+ """Another process has the serial port open; do not attempt to reflash."""
42
+
43
+
44
+ class LivePortMissingError(LiveConnectionError):
45
+ """The selected COM port disappeared or was not present."""
46
+
47
+
48
+ EXPECTED_FIRMWARE = "5.4.4"
49
+
50
+
51
+ def _port_is_busy(exc: BaseException) -> bool:
52
+ """Identify a locked COM port in the underlying pyserial/Windows error.
53
+
54
+ A failed firmware identity check is expected after a standalone upload.
55
+ Access denied, on the other hand, is *not* evidence that the firmware is
56
+ wrong; trying avrdude in this case just gives a second misleading error.
57
+ """
58
+ seen: set[int] = set()
59
+ current: Optional[BaseException] = exc
60
+ while current is not None and id(current) not in seen:
61
+ seen.add(id(current))
62
+ message = str(current).lower()
63
+ if any(word in message for word in (
64
+ "access is denied", "permissionerror(13", "port is busy",
65
+ "port busy", "resource busy", "device or resource busy",
66
+ "permission denied",
67
+ )):
68
+ return True
69
+ if isinstance(current, PermissionError):
70
+ return True
71
+ if isinstance(current, OSError) and getattr(current, "winerror", None) in (5, 32):
72
+ return True
73
+ current = current.__cause__ or current.__context__
74
+ return False
75
+
76
+
77
+ def _port_is_missing(exc: BaseException) -> bool:
78
+ seen: set[int] = set()
79
+ current: Optional[BaseException] = exc
80
+ while current is not None and id(current) not in seen:
81
+ seen.add(id(current))
82
+ message = str(current).lower()
83
+ if (isinstance(current, FileNotFoundError) or
84
+ isinstance(current, OSError) and getattr(current, "winerror", None) in (2, 3) or
85
+ "file not found" in message or
86
+ "the system cannot find the file specified" in message):
87
+ return True
88
+ current = current.__cause__ or current.__context__
89
+ return False
90
+
91
+
92
+ def _patch_pinned_telemetrix_identity_wait() -> None:
93
+ """Give Telemetrix 1.43 enough time to dispatch the Uno identity reply.
94
+
95
+ The pinned client waits only 0.5 seconds after sending ARE_U_THERE. On
96
+ Windows, its background receiver/reporter threads can dispatch the valid
97
+ three-byte response just after that deadline. Keep the pinned protocol and
98
+ extend only this internal wait; future client versions are left untouched.
99
+ """
100
+ try:
101
+ from telemetrix import telemetrix as client
102
+ from telemetrix.private_constants import PrivateConstants
103
+ except Exception:
104
+ return
105
+ cls = client.Telemetrix
106
+ if getattr(cls, "_barmaja_identity_wait_patch", False):
107
+ return
108
+ if str(getattr(PrivateConstants, "TELEMETRIX_VERSION", "")) != "1.43":
109
+ return
110
+
111
+ def _get_arduino_id(self) -> None:
112
+ self._send_command([PrivateConstants.ARE_U_THERE])
113
+ deadline = time.monotonic() + 3.0
114
+ while self.reported_arduino_id is None and time.monotonic() < deadline:
115
+ time.sleep(0.05)
116
+
117
+ cls._get_arduino_id = _get_arduino_id
118
+ cls._barmaja_identity_wait_patch = True
119
+
120
+
121
+ def _port_was_lost(exc: BaseException) -> bool:
122
+ """True for errors that mean the USB serial link is closed or gone.
123
+
124
+ Covers pyserial ``PortNotOpenError`` ("Attempting to use a port that is not
125
+ open"), write/read failures after unplugging, and Windows ClearCommError.
126
+ """
127
+ seen: set[int] = set()
128
+ current: Optional[BaseException] = exc
129
+ while current is not None and id(current) not in seen:
130
+ seen.add(id(current))
131
+ name = type(current).__name__.lower()
132
+ message = str(current).lower()
133
+ if ("portnotopen" in name or "not open" in message or "port is not open" in message
134
+ or "write fail in _send_command" in message or "shutdown failed" in message
135
+ or "clearcommerror" in message or "device not configured" in message
136
+ or "input/output error" in message or "device disconnected" in message
137
+ or "device attached to the system is not functioning" in message
138
+ or "writefile failed" in message or "readfile failed" in message
139
+ or "handle is invalid" in message):
140
+ return True
141
+ if _port_is_missing(current):
142
+ return True
143
+ current = current.__cause__ or current.__context__
144
+ return False
145
+
146
+
147
+ class _GuardedSync:
148
+ """Wrap Telemetrix calls so a lost USB port is reported ONCE and cleanly.
149
+
150
+ Without this the upstream client (``shutdown_on_exception=True``) keeps
151
+ calling into a closed serial handle and a student sees a long chain of
152
+ repeated ``PortNotOpenError`` tracebacks. Any error that proves the port
153
+ is gone stops LiveRun, releases everything and raises one short message.
154
+ """
155
+
156
+ def __init__(self, target: Any, owner: "_TelemetrixBackend") -> None:
157
+ object.__setattr__(self, "_target", target)
158
+ object.__setattr__(self, "_owner", owner)
159
+
160
+ def __getattr__(self, name: str) -> Any:
161
+ value = getattr(self._target, name)
162
+ if name in ("_context", "board"):
163
+ return _GuardedSync(value, self._owner)
164
+ if callable(value):
165
+ owner = self._owner
166
+
167
+ def guarded(*args: Any, **kwargs: Any) -> Any:
168
+ try:
169
+ return value(*args, **kwargs)
170
+ except LiveRunError:
171
+ raise
172
+ except BaseException as exc:
173
+ if isinstance(exc, (KeyboardInterrupt, SystemExit, GeneratorExit)):
174
+ raise
175
+ if owner._closed or _port_was_lost(exc):
176
+ owner._port_lost(exc)
177
+ raise
178
+ return guarded
179
+ return value
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class LiveSession:
184
+ """Small status object returned by ``LiveRun()``.
185
+
186
+ Students do not need to store this object; it is useful for diagnostics and
187
+ advanced code that wants to display the selected COM port or firmware.
188
+ """
189
+
190
+ port: str
191
+ firmware_version: str
192
+
193
+ def shutdown(self) -> None:
194
+ LiveStop()
195
+
196
+
197
+ class _TelemetrixBackend:
198
+ def __init__(self, port: str) -> None:
199
+ try:
200
+ import telemetrix_Barmaja as tb
201
+ except Exception as exc:
202
+ raise LiveDependencyError(
203
+ "LiveRun needs the Barmaja Telemetrix support package. "
204
+ "Install the Barmaja PyArduino LiveRun/Offline pack and run again."
205
+ ) from exc
206
+
207
+ _patch_pinned_telemetrix_identity_wait()
208
+ self.tb = tb
209
+ capture = io.StringIO()
210
+ try:
211
+ # The upstream client prints a long banner and connection transcript.
212
+ # Keep the beginner-facing console clean; Barmaja prints a short status
213
+ # after a successful connection instead.
214
+ with contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture):
215
+ self.sync = tb.TelemetrixSync(
216
+ com_port=port,
217
+ arduino_wait=float(os.environ.get("BARMAJA_LIVE_ARDUINO_WAIT", "3.0")),
218
+ shutdown_on_exception=True,
219
+ )
220
+ except Exception as exc:
221
+ if _port_is_busy(exc):
222
+ raise LivePortBusyError(
223
+ f"Arduino Uno found on {port}, but its port is busy. "
224
+ "Close other programs using this Arduino and try again."
225
+ ) from exc
226
+ if _port_is_missing(exc):
227
+ raise LivePortMissingError(
228
+ f"Arduino Uno is no longer available on {port}. "
229
+ "Reconnect USB and try again."
230
+ ) from exc
231
+ # The long upstream transcript belongs only in debugging tools;
232
+ # it is not helpful to students when a standalone sketch is running.
233
+ raise LiveConnectionError(
234
+ "Arduino is not running LiveRun firmware yet."
235
+ ) from exc
236
+
237
+ self.port = port
238
+ try:
239
+ self._raw_board = self.sync._context.board
240
+ except Exception:
241
+ self._raw_board = None
242
+ # Telemetrix 1.43 with shutdown_on_exception=True recurses on a dead port:
243
+ # _send_command -> shutdown() -> _send_command ... Once connected, let
244
+ # BARMAJA own cleanup instead (see _release_port / _port_lost).
245
+ if self._raw_board is not None:
246
+ try:
247
+ self._raw_board.shutdown_on_exception = False
248
+ except Exception:
249
+ pass
250
+ self._link_broken = False
251
+ self.sync = _GuardedSync(self.sync, self)
252
+ _install_thread_hook()
253
+ self._wire_address: Optional[int] = None
254
+ self._wire_tx: list[int] = []
255
+ self._wire_rx: list[int] = []
256
+ self._dht_started: set[tuple[int, int]] = set()
257
+ self._sonars: set[tuple[int, int]] = set()
258
+ self._closed = False
259
+
260
+ @property
261
+ def firmware_version(self) -> str:
262
+ try:
263
+ values = self.sync._context.board.firmware_version
264
+ if values and len(values) >= 3:
265
+ return ".".join(str(int(v)) for v in values[:3])
266
+ except Exception:
267
+ pass
268
+ return "unknown"
269
+
270
+ @staticmethod
271
+ def _analog_channel(pin: int) -> int:
272
+ """Translate Barmaja's Arduino aliases A0..A5 (14..19) for Telemetrix."""
273
+ pin = int(pin)
274
+ if 14 <= pin <= 19:
275
+ return pin - 14
276
+ if 0 <= pin <= 5:
277
+ return pin
278
+ raise ValueError("analogRead() on Arduino Uno expects A0..A5.")
279
+
280
+ def _link_ok(self) -> bool:
281
+ """Cheap health check: port open and the Telemetrix receiver thread alive.
282
+
283
+ Without this, cached readings (e.g. analogRead) silently repeat the last
284
+ value forever after the USB cable is pulled.
285
+ """
286
+ if getattr(self, "_link_broken", False):
287
+ return False
288
+ board = getattr(self, "_raw_board", None)
289
+ if board is None:
290
+ return True
291
+ try:
292
+ port = getattr(board, "serial_port", None)
293
+ if port is not None and not getattr(port, "is_open", True):
294
+ return False
295
+ receiver = getattr(board, "the_data_receive_thread", None)
296
+ if receiver is not None and receiver.ident is not None and not receiver.is_alive():
297
+ return False
298
+ # Active probe (rate-limited). Telemetrix 1.43's receiver thread
299
+ # swallows OSError -- and pyserial's SerialException IS an OSError --
300
+ # so after a USB unplug it spins forever and the cached readings go
301
+ # stale silently. On Windows ``in_waiting`` raises
302
+ # "ClearCommError failed" for a removed device.
303
+ if port is not None:
304
+ now = time.monotonic()
305
+ if now - getattr(self, "_last_probe", 0.0) >= 0.1:
306
+ self._last_probe = now
307
+ try:
308
+ port.in_waiting
309
+ except Exception as exc:
310
+ if _port_was_lost(exc) or isinstance(exc, OSError):
311
+ return False
312
+ except Exception:
313
+ return False
314
+ return True
315
+
316
+ def _check(self) -> None:
317
+ if self._closed:
318
+ raise LiveRunError("LiveRun has already been stopped. Call LiveRun() again.")
319
+ if not self._link_ok():
320
+ self._port_lost(LivePortMissingError(f"USB link lost on {self.port}"))
321
+
322
+ def _release_port(self) -> None:
323
+ """Stop Telemetrix threads and ALWAYS close the COM handle, quietly.
324
+
325
+ Upstream shutdown() first sends STOP_ALL_REPORTS; on a dead port that
326
+ raises before serial_port.close(), leaving the COM port held open inside
327
+ IDLE/PictoBlox so the next LiveRun() reports "port busy".
328
+ """
329
+ quiet = io.StringIO()
330
+ board = getattr(self, "_raw_board", None)
331
+ with contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet):
332
+ if board is None or self._link_ok():
333
+ try:
334
+ self.sync._target.shutdown()
335
+ except BaseException as exc:
336
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)):
337
+ raise
338
+ if board is not None:
339
+ try:
340
+ board.shutdown_flag = True
341
+ board._stop_threads()
342
+ except Exception:
343
+ pass
344
+ port = getattr(board, "serial_port", None)
345
+ if port is not None:
346
+ try:
347
+ port.close()
348
+ except Exception:
349
+ pass
350
+ try:
351
+ self.sync._target._context._closed = True
352
+ except Exception:
353
+ pass
354
+
355
+ def _port_lost(self, exc: BaseException) -> None:
356
+ """Stop LiveRun after USB loss: release once, raise one actionable error."""
357
+ global _active_backend, _active_session
358
+ already = self._closed
359
+ self._closed = True
360
+ if _active_backend is self:
361
+ _active_backend = None
362
+ _active_session = None
363
+ if not already:
364
+ self._link_broken = True
365
+ try:
366
+ self._release_port()
367
+ except BaseException:
368
+ pass
369
+ _enable_student_error_display()
370
+ error = LiveStudentError(
371
+ f"Arduino connection lost on {self.port}.\n"
372
+ "Reconnect the USB cable, close other programs using the Arduino, "
373
+ "and run the program again."
374
+ )
375
+ error._barmaja_student_message = True
376
+ raise error from None
377
+
378
+ # ---------- core Arduino-style API ----------
379
+ def pin_mode(self, pin: int, mode: int) -> None:
380
+ self._check()
381
+ self.sync.pinMode(int(pin), int(mode))
382
+
383
+ def digital_write(self, pin: int, value: int) -> None:
384
+ self._check()
385
+ self.sync.digitalWrite(int(pin), int(value))
386
+
387
+ def digital_read(self, pin: int) -> int:
388
+ self._check()
389
+ return int(self.sync.digitalRead(int(pin)))
390
+
391
+ def analog_read(self, pin: int) -> int:
392
+ self._check()
393
+ return int(self.sync.analogRead(self._analog_channel(pin)))
394
+
395
+ def analog_write(self, pin: int, value: int) -> None:
396
+ self._check()
397
+ self.sync.analogWrite(int(pin), int(value))
398
+
399
+ def delay(self, milliseconds: int) -> None:
400
+ self._check()
401
+ self.sync.delay(int(milliseconds))
402
+
403
+ def millis(self) -> int:
404
+ self._check()
405
+ return int(self.sync.millis())
406
+
407
+ # ---------- servo ----------
408
+ def servo_attach(self, pin: int, min_us: Optional[int] = None, max_us: Optional[int] = None) -> None:
409
+ self._check()
410
+ ctx = self.sync._context
411
+ pin = int(pin)
412
+ if ctx._set_servo is None:
413
+ raise LiveRunError("Servo is not supported by this Telemetrix firmware.")
414
+ if min_us is None:
415
+ try:
416
+ ctx._set_servo(pin)
417
+ except TypeError:
418
+ ctx._set_servo(pin, 544, 2400)
419
+ else:
420
+ try:
421
+ ctx._set_servo(pin, int(min_us), int(max_us))
422
+ except TypeError:
423
+ ctx._set_servo(pin)
424
+ ctx._servos.add(pin)
425
+
426
+ def servo_write(self, pin: int, angle: int) -> None:
427
+ self._check()
428
+ self.sync.servoWrite(int(pin), int(angle))
429
+
430
+ def servo_write_microseconds(self, pin: int, microseconds: int) -> None:
431
+ self._check()
432
+ pin = int(pin)
433
+ us = int(microseconds)
434
+ board = self.sync._context.board
435
+ method = getattr(board, "servo_write_microseconds", None)
436
+ if method is None:
437
+ # Official Telemetrix exposes degrees, not Servo.writeMicroseconds().
438
+ # Convert the standard 544..2400 us Arduino range to 0..180 degrees.
439
+ us = max(544, min(2400, us))
440
+ angle = round((us - 544) * 180 / (2400 - 544))
441
+ self.sync.servoWrite(pin, angle)
442
+ else:
443
+ method(pin, us)
444
+
445
+ def servo_detach(self, pin: int) -> None:
446
+ self._check()
447
+ pin = int(pin)
448
+ ctx = self.sync._context
449
+ method = getattr(ctx.board, "servo_detach", None)
450
+ if method is None:
451
+ raise LiveRunError("Servo detach is not supported by this Telemetrix build.")
452
+ method(pin)
453
+ ctx._servos.discard(pin)
454
+
455
+ def servo_attached(self, pin: int) -> bool:
456
+ self._check()
457
+ return int(pin) in self.sync._context._servos
458
+
459
+ # ---------- HC-SR04 ----------
460
+ def ultrasonic_begin(self, trigger_pin: int, echo_pin: int) -> None:
461
+ self._check()
462
+ key = (int(trigger_pin), int(echo_pin))
463
+ if key not in self._sonars:
464
+ self.sync.sonarBegin(*key)
465
+ self._sonars.add(key)
466
+
467
+ def ultrasonic_read(self, trigger_pin: int, echo_pin: int) -> float:
468
+ self.ultrasonic_begin(trigger_pin, echo_pin)
469
+ return float(self.sync.sonarReadCM(int(trigger_pin), int(echo_pin)))
470
+
471
+ # ---------- DHT ----------
472
+ def dht_begin(self, pin: int, sensor_type: int) -> None:
473
+ self._check()
474
+ key = (int(pin), int(sensor_type))
475
+ if key not in self._dht_started:
476
+ self.sync.dhtBegin(*key)
477
+ self._dht_started.add(key)
478
+
479
+ def dht_last(self, pin: int, sensor_type: int) -> tuple[Optional[float], Optional[float]]:
480
+ self.dht_begin(pin, sensor_type)
481
+ temperature, humidity = self.sync.dhtLast(int(pin))
482
+ return temperature, humidity
483
+
484
+ # ---------- I2C / Wire ----------
485
+ def wire_begin(self) -> None:
486
+ self._check()
487
+ self.sync.i2cBegin(0)
488
+
489
+ def wire_begin_transmission(self, address: int) -> None:
490
+ self.wire_begin()
491
+ self._wire_address = int(address)
492
+ self._wire_tx = []
493
+
494
+ def wire_write(self, value: int) -> int:
495
+ if self._wire_address is None:
496
+ raise LiveRunError("Call Wire.beginTransmission(address) before Wire.write().")
497
+ self._wire_tx.append(int(value) & 0xFF)
498
+ return 1
499
+
500
+ def wire_end_transmission(self) -> int:
501
+ if self._wire_address is None:
502
+ raise LiveRunError("Call Wire.beginTransmission(address) before Wire.endTransmission().")
503
+ self.sync.i2cWrite(self._wire_address, self._wire_tx, i2c_port=0)
504
+ self._wire_address = None
505
+ self._wire_tx = []
506
+ return 0
507
+
508
+ def wire_request_from(self, address: int, count: int) -> int:
509
+ self.wire_begin()
510
+ data = self.sync.i2cRead(
511
+ int(address), None, int(count), i2c_port=0, write_register=False
512
+ )
513
+ self._wire_rx = list(data)
514
+ return len(self._wire_rx)
515
+
516
+ def wire_available(self) -> int:
517
+ return len(self._wire_rx)
518
+
519
+ def wire_read(self) -> int:
520
+ if not self._wire_rx:
521
+ return -1
522
+ return int(self._wire_rx.pop(0))
523
+
524
+ # ---------- LCD ----------
525
+ def lcd_create(self, address: int, columns: int, rows: int):
526
+ self._check()
527
+ try:
528
+ lcd_cls = self.tb.LCD1602
529
+ except AttributeError as exc:
530
+ raise LiveRunError("LCD support is missing from telemetrix_Barmaja.") from exc
531
+ return lcd_cls(self.sync._context, address=int(address), columns=int(columns), rows=int(rows))
532
+
533
+ # ---------- lifecycle ----------
534
+ def shutdown(self) -> None:
535
+ if self._closed:
536
+ return
537
+ self._closed = True
538
+ # A port that is already gone must not produce a second traceback.
539
+ self._release_port()
540
+
541
+
542
+ _active_backend: Optional[_TelemetrixBackend] = None
543
+ _active_session: Optional[LiveSession] = None
544
+ _atexit_registered = False
545
+ _thread_hook_installed = False
546
+
547
+
548
+ def _install_thread_hook() -> None:
549
+ """Silence Telemetrix background-thread tracebacks caused by a lost port.
550
+
551
+ When USB is unplugged the upstream receiver thread dies with a
552
+ SerialException/PortNotOpenError traceback printed by ``threading``. That
553
+ noise is replaced by ONE clean message from the next LiveRun call. Any
554
+ other thread error is passed to the previous hook unchanged.
555
+ """
556
+ global _thread_hook_installed
557
+ if _thread_hook_installed:
558
+ return
559
+ import threading
560
+ previous = threading.excepthook
561
+
562
+ def hook(args) -> None:
563
+ backend_ = _active_backend
564
+ exc = args.exc_value
565
+ board = getattr(backend_, "_raw_board", None) if backend_ is not None else None
566
+ owned = board is not None and args.thread in (
567
+ getattr(board, "the_data_receive_thread", None),
568
+ getattr(board, "the_reporter_thread", None),
569
+ )
570
+ if exc is not None and (owned or "serial" in type(exc).__module__.lower()) and (
571
+ _port_was_lost(exc) or "serialexception" in type(exc).__name__.lower()):
572
+ if backend_ is not None:
573
+ backend_._link_broken = True
574
+ return
575
+ previous(args)
576
+
577
+ threading.excepthook = hook
578
+ _thread_hook_installed = True
579
+
580
+
581
+ def _say(text: str, *, error: bool = False, style: Optional[str] = None) -> None:
582
+ try:
583
+ from .console import write_styled
584
+ import sys
585
+ stream = sys.stderr if error else sys.stdout
586
+ write_styled(stream, text, style=style)
587
+ stream.flush()
588
+ except Exception:
589
+ import builtins
590
+ builtins.print(text)
591
+
592
+
593
+ def _select_port(port: Optional[str]) -> str:
594
+ from .toolchain import board_for_port, detect_board
595
+ if port:
596
+ return board_for_port(port).port
597
+ return detect_board().port
598
+
599
+
600
+ def _telemetrix_sketch() -> Optional[Path]:
601
+ from .environment import private_arduino_library_roots
602
+ for library_root in private_arduino_library_roots():
603
+ sketch = (
604
+ library_root
605
+ / "Telemetrix4Arduino"
606
+ / "examples"
607
+ / "Telemetrix4Arduino"
608
+ / "Telemetrix4Arduino.ino"
609
+ )
610
+ if sketch.is_file():
611
+ return sketch
612
+ return None
613
+
614
+
615
+ def _upload_live_firmware(port: str) -> None:
616
+ sketch = _telemetrix_sketch()
617
+ if sketch is None:
618
+ raise LiveRunError(
619
+ "Telemetrix4Arduino firmware is not installed in Barmaja's offline libraries. "
620
+ "Install/reinstall the Barmaja PyArduino Full or Light Offline pack."
621
+ )
622
+
623
+ from .toolchain import board_for_port, compile_existing_sketch, upload_compiled
624
+
625
+ _say("Preparing Arduino Uno for LiveRun...")
626
+ artifact = compile_existing_sketch(sketch.parent)
627
+ board = board_for_port(port)
628
+ # After a failed client identity check Windows can take a short moment to
629
+ # release the serial handle. Retry only a *busy-port upload*, never a
630
+ # compile failure or another type of avrdude failure.
631
+ for attempt in range(2):
632
+ try:
633
+ upload_compiled(artifact, board)
634
+ break
635
+ except Exception as exc:
636
+ if attempt != 0 or not _port_is_busy(exc):
637
+ raise
638
+ time.sleep(1.0)
639
+ # Give the Uno a short moment after avrdude closes the port. The Telemetrix
640
+ # client performs its own reset wait when it opens the connection.
641
+ time.sleep(0.5)
642
+
643
+
644
+ def _try_connect(port: str) -> _TelemetrixBackend:
645
+ return _TelemetrixBackend(port)
646
+
647
+
648
+ def _connect_after_firmware_upload(port: str) -> _TelemetrixBackend:
649
+ """Let Uno USB reset / Telemetrix handshake settle after an AVR flash.
650
+
651
+ Do not compile or reflash again on these retries. A locked port must be
652
+ reported as locked instead of retried as if the firmware were missing.
653
+ """
654
+ last_error: Optional[Exception] = None
655
+ for attempt in range(3):
656
+ if attempt:
657
+ time.sleep(1.0)
658
+ try:
659
+ return _try_connect(port)
660
+ except LivePortBusyError:
661
+ raise
662
+ except LiveConnectionError as exc:
663
+ last_error = exc
664
+ raise LiveRunError(
665
+ "Arduino firmware was uploaded, but the LiveRun connection did not start. "
666
+ "Unplug/reconnect USB, close other Arduino programs, and try again."
667
+ ) from last_error
668
+
669
+
670
+ def _LiveRun_core(port: Optional[str] = None, *, auto_firmware: bool = True) -> LiveSession:
671
+ """Start live PC/PictoBlox control of the connected Arduino Uno.
672
+
673
+ The first call connects to Telemetrix4Arduino. If the Uno is running a
674
+ normal standalone sketch and ``auto_firmware`` is true, Barmaja uploads the
675
+ pinned Telemetrix4Arduino firmware and reconnects automatically.
676
+
677
+ After this call, the usual Barmaja API (``pinMode``, ``analogRead``,
678
+ ``digitalWrite``, ``Servo``, etc.) executes live instead of raising the
679
+ desktop guard. Normal ``print()`` remains ordinary Python output, so in
680
+ PictoBlox it appears in the Python console.
681
+ """
682
+ global _active_backend, _active_session, _atexit_registered
683
+
684
+ if _active_backend is not None and not _active_backend._closed:
685
+ if port and str(port).strip().upper() != _active_backend.port.upper():
686
+ raise LiveRunError(
687
+ f"LiveRun is already connected on {_active_backend.port}. "
688
+ "Call LiveStop() before selecting another port."
689
+ )
690
+ return _active_session # type: ignore[return-value]
691
+
692
+ chosen_port = _select_port(port)
693
+ firmware_changed = False
694
+ try:
695
+ backend = _try_connect(chosen_port)
696
+ except LiveDependencyError:
697
+ raise
698
+ except LivePortBusyError:
699
+ # Do not flash a busy port. A locked COM3 is not evidence of missing
700
+ # firmware, and attempting upload only masks the actionable problem.
701
+ raise
702
+ except LivePortMissingError:
703
+ raise
704
+ except LiveConnectionError:
705
+ if not auto_firmware:
706
+ raise
707
+ _upload_live_firmware(chosen_port)
708
+ firmware_changed = True
709
+ backend = _connect_after_firmware_upload(chosen_port)
710
+
711
+ # A Telemetrix board may answer successfully while running an older server.
712
+ # LiveRun is validated against one pinned firmware version, so update it
713
+ # automatically instead of silently accepting an untested protocol.
714
+ if backend.firmware_version != EXPECTED_FIRMWARE:
715
+ if not auto_firmware:
716
+ backend.shutdown()
717
+ raise LiveRunError(
718
+ f"LiveRun requires Telemetrix4Arduino {EXPECTED_FIRMWARE}; "
719
+ f"the board reported {backend.firmware_version}."
720
+ )
721
+ backend.shutdown()
722
+ _upload_live_firmware(chosen_port)
723
+ firmware_changed = True
724
+ backend = _connect_after_firmware_upload(chosen_port)
725
+ if backend.firmware_version != EXPECTED_FIRMWARE:
726
+ backend.shutdown()
727
+ raise LiveRunError(
728
+ f"Arduino reported Telemetrix firmware {backend.firmware_version} after update; "
729
+ f"expected {EXPECTED_FIRMWARE}."
730
+ )
731
+
732
+ _active_backend = backend
733
+ _active_session = LiveSession(chosen_port, backend.firmware_version)
734
+ if not _atexit_registered:
735
+ atexit.register(LiveStop)
736
+ _atexit_registered = True
737
+
738
+ _say("Barmaja PyArduino LiveRun ready.", style="success")
739
+ _say(f"Arduino Uno: {chosen_port}")
740
+ if firmware_changed:
741
+ _say("LiveRun firmware was installed automatically.", style="success")
742
+ return _active_session
743
+
744
+
745
+
746
+ def _clean_liverun_error(exc: Exception, selected_port: Optional[str], auto_firmware: bool) -> str:
747
+ """Map known connection/firmware failures to approved student help text.
748
+
749
+ Keep other error families out of this function so genuine programming bugs
750
+ are still surfaced by Python with their real tracebacks.
751
+ """
752
+ from .errors import BoardDetectionError, ToolchainError
753
+
754
+ port = (selected_port or "").strip().upper()
755
+ if isinstance(exc, BoardDetectionError):
756
+ if exc.reason == "not-found":
757
+ return "Arduino Uno not detected.\nConnect the Arduino by USB and try again."
758
+ if exc.reason == "multiple":
759
+ options = ", ".join(exc.ports)
760
+ return (
761
+ "More than one Arduino Uno or possible board was detected.\n"
762
+ "Disconnect extra boards or choose a port with LiveRun(port='COM3')."
763
+ + (" Available ports: " + options if options else "")
764
+ )
765
+ return "Could not scan for the Arduino Uno.\nCheck the USB connection and Barmaja offline installation."
766
+ if isinstance(exc, LivePortBusyError) or _port_is_busy(exc):
767
+ return (
768
+ "Arduino Uno found on {}, but the port is busy.\n"
769
+ "Close any program using {} and try again."
770
+ ).format(port or "the selected port", port or "that port")
771
+ if isinstance(exc, LivePortMissingError) or _port_is_missing(exc):
772
+ return "Arduino connection lost.\nReconnect the Arduino and run the program again."
773
+ if isinstance(exc, LiveDependencyError):
774
+ return "LiveRun support is missing.\nReinstall the Barmaja PyArduino offline package."
775
+ if isinstance(exc, LiveConnectionError) and not auto_firmware:
776
+ return "Could not connect to the Arduino for LiveRun.\nCheck the firmware or enable automatic firmware preparation."
777
+ if isinstance(exc, (LiveRunError, ToolchainError)):
778
+ return "Could not prepare the Arduino for LiveRun.\nReconnect the board and try again."
779
+ raise TypeError("This is not a recognized LiveRun setup error")
780
+
781
+
782
+ def _enable_student_error_display() -> None:
783
+ """Quiet only expected student errors in standard Python.
784
+
785
+ Other exception classes are delegated unchanged to the existing hook.
786
+ Some IDEs (notably embedded PictoBlox) own exception rendering, so a
787
+ physical PictoBlox check is still necessary.
788
+ """
789
+ previous = sys.excepthook
790
+ if getattr(previous, "_barmaja_liverun_clean_hook", False):
791
+ return
792
+
793
+ def display(exc_type, exc, traceback):
794
+ if isinstance(exc, LiveStudentError) or getattr(exc, "_barmaja_student_message", False):
795
+ sys.stderr.write(str(exc).rstrip() + "\n")
796
+ sys.stderr.flush()
797
+ return
798
+ previous(exc_type, exc, traceback)
799
+
800
+ display._barmaja_liverun_clean_hook = True
801
+ sys.excepthook = display
802
+
803
+
804
+ def LiveRun(port: Optional[str] = None, *, auto_firmware: bool = True,
805
+ debug: bool = False) -> LiveSession:
806
+ """Start LiveRun. ``debug=True`` preserves original technical exceptions.
807
+
808
+ Normal startup failures use concise explanations. Technical causes remain
809
+ accessible through exception chaining and in debug mode.
810
+ """
811
+ if debug:
812
+ return _LiveRun_core(port, auto_firmware=auto_firmware)
813
+ try:
814
+ return _LiveRun_core(port, auto_firmware=auto_firmware)
815
+ except LiveStudentError:
816
+ raise
817
+ except Exception as exc:
818
+ from .errors import BoardDetectionError, ToolchainError
819
+ if not isinstance(exc, (LiveRunError, BoardDetectionError, ToolchainError)):
820
+ raise
821
+ selected_port = port
822
+ # The selected port is known after detection in most cases, but do not
823
+ # trigger a second hardware scan just to improve an error message.
824
+ if not selected_port:
825
+ # LiveConnectionError / port errors sometimes carry a port name.
826
+ # Never guess COM3 if the user's board actually has another port.
827
+ import re
828
+ match = re.search(r"\bCOM[0-9]+\b", str(exc), re.IGNORECASE)
829
+ if match:
830
+ selected_port = match.group(0)
831
+ _enable_student_error_display()
832
+ message = _clean_liverun_error(exc, selected_port, auto_firmware)
833
+ # Preserve exception subclasses used by the recovery protocol and
834
+ # existing callers/tests. Only the displayed message is rewritten.
835
+ shown = type(exc)(message) if isinstance(exc, (LivePortBusyError, LivePortMissingError)) else LiveStudentError(message)
836
+ shown._barmaja_student_message = True
837
+ raise shown from None
838
+
839
+ def LiveStop() -> None:
840
+ """Stop the current LiveRun connection, if one is active."""
841
+ global _active_backend, _active_session
842
+ backend = _active_backend
843
+ _active_backend = None
844
+ _active_session = None
845
+ if backend is not None:
846
+ try:
847
+ backend.shutdown()
848
+ except Exception:
849
+ pass
850
+
851
+
852
+ def is_live() -> bool:
853
+ return _active_backend is not None and not _active_backend._closed
854
+
855
+
856
+ def backend() -> _TelemetrixBackend:
857
+ if _active_backend is None or _active_backend._closed:
858
+ raise LiveRunError("LiveRun is not active. Add LiveRun() near the top of the program.")
859
+ return _active_backend