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,595 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import dataclass, asdict
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+ CACHE_SCHEMA = 2
16
+
17
+
18
+ def _home() -> Path:
19
+ override = os.environ.get("BARMAJA_HOME")
20
+ if override:
21
+ return Path(override).expanduser().resolve()
22
+ base = Path(os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or Path.home())
23
+ return (base / "BarmajaPyArduino").resolve()
24
+
25
+
26
+ def ascii_runtime_root() -> Path:
27
+ override = os.environ.get("BARMAJA_RUNTIME_ROOT")
28
+ if override:
29
+ return Path(override).expanduser().resolve()
30
+ if os.name == "nt":
31
+ drive = os.environ.get("SystemDrive", "C:")
32
+ return Path(drive + "\\BarmajaPyArduino")
33
+ return Path(tempfile.gettempdir()) / "barmaja-pyarduino"
34
+
35
+
36
+ def ensure_runtime_dirs() -> dict[str, Path]:
37
+ root = ascii_runtime_root()
38
+ dirs = {
39
+ "root": root,
40
+ "data": root / "arduino-data",
41
+ "user": root / "arduino-user",
42
+ "downloads": root / "downloads",
43
+ "build": root / "build",
44
+ "cache": _home() / "cache",
45
+ }
46
+ for p in dirs.values():
47
+ p.mkdir(parents=True, exist_ok=True)
48
+ return dirs
49
+
50
+
51
+ def private_arduino_library_roots() -> tuple[Path, ...]:
52
+ """Existing BARMAJA private Arduino library roots, in priority order.
53
+
54
+ Restored in 1.1.0.dev7: ``live.py`` (LiveRun firmware install) imports this
55
+ function, but it was missing from the 0.7.0.dev1 "student-errors" build and
56
+ every V1.1 dev build, so ``upload()`` followed by ``LiveRun()`` failed with
57
+ ImportError. Installed packs use ``arduino-libraries``; portable/source
58
+ packs use ``arduino_libraries``. Both are accepted.
59
+ """
60
+ root = ensure_runtime_dirs()["root"]
61
+ found: list[Path] = []
62
+ seen: set[str] = set()
63
+ for candidate in (root / "arduino-libraries", root / "arduino_libraries"):
64
+ if not candidate.is_dir():
65
+ continue
66
+ try:
67
+ resolved = Path(os.path.abspath(os.fspath(candidate))) if os.name == "nt" else candidate.resolve()
68
+ except OSError:
69
+ resolved = candidate
70
+ key = os.path.normcase(str(resolved))
71
+ if key not in seen:
72
+ found.append(resolved)
73
+ seen.add(key)
74
+ return tuple(found)
75
+
76
+
77
+ def package_version() -> str:
78
+ # The package's own version is authoritative: PictoBlox copies have no
79
+ # dist-info, and a stale dist-info must never mask the code actually loaded.
80
+ from . import __version__
81
+ return __version__
82
+
83
+
84
+ def arduino_cli_environment(executable: str | Path | None = None) -> dict[str, str]:
85
+ """Environment for every Arduino CLI call Barmaja makes.
86
+
87
+ - English output, so memory reports and error translation are stable on
88
+ Arabic/French/... Windows installations;
89
+ - no network update notification (offline classrooms);
90
+ - an ASCII-only build cache (non-ASCII Windows user names break some AVR tools);
91
+ - when the pinned Full-Pack CLI is used, ignore inherited ``ARDUINO_*``
92
+ variables so another Arduino/ESP32 installation cannot redirect Barmaja
93
+ away from its tested offline runtime.
94
+
95
+ Only Barmaja's own subprocesses see these values; the user's Arduino IDE
96
+ configuration files and machine/user environment variables are never modified.
97
+ """
98
+ env = dict(os.environ)
99
+
100
+ # The Full Offline runtime must be isolated from machine/user Arduino
101
+ # overrides. External/Light-Pack Arduino CLI installations keep their
102
+ # normal environment so Barmaja does not interfere with a user's IDE setup.
103
+ bundled = False
104
+ if executable:
105
+ try:
106
+ exe = Path(str(executable)).expanduser().resolve()
107
+ runtime = (ascii_runtime_root() / "runtime").resolve()
108
+ bundled = exe.parent == runtime and exe.name.lower() in {"arduino-cli", "arduino-cli.exe"}
109
+ except OSError:
110
+ bundled = False
111
+ if bundled:
112
+ for key in tuple(env):
113
+ if key.upper().startswith("ARDUINO_"):
114
+ env.pop(key, None)
115
+
116
+ env["ARDUINO_LOCALE"] = "en"
117
+ env["ARDUINO_UPDATER_ENABLE_NOTIFICATION"] = "false"
118
+ env["ARDUINO_METRICS_ENABLED"] = "false"
119
+ try:
120
+ cache = ascii_runtime_root() / "build-cache"
121
+ cache.mkdir(parents=True, exist_ok=True)
122
+ env["ARDUINO_BUILD_CACHE_PATH"] = str(cache)
123
+ except OSError:
124
+ pass
125
+ return env
126
+
127
+
128
+ def _kill_process_tree(proc: subprocess.Popen) -> None:
129
+ try:
130
+ if os.name == "nt":
131
+ subprocess.run(
132
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
133
+ capture_output=True, check=False, timeout=15,
134
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
135
+ )
136
+ else:
137
+ import signal
138
+ os.killpg(proc.pid, signal.SIGKILL)
139
+ except Exception:
140
+ pass
141
+ try:
142
+ proc.kill()
143
+ except Exception:
144
+ pass
145
+
146
+
147
+ def run_text(args: list[str], *, timeout: float = 20.0, cwd: str | Path | None = None) -> subprocess.CompletedProcess[str]:
148
+ """Run an external tool with UTF-8 capture and a hard timeout.
149
+
150
+ On timeout the whole process tree (arduino-cli -> avr-gcc/avrdude) is
151
+ terminated so no orphaned compiler keeps build files or the COM port locked.
152
+ """
153
+ from .errors import ToolchainError
154
+ kwargs: dict = dict(
155
+ cwd=str(cwd) if cwd else None,
156
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL,
157
+ text=True, encoding="utf-8", errors="replace",
158
+ env=arduino_cli_environment(args[0] if args else None),
159
+ )
160
+ if os.name == "nt":
161
+ # No console windows flashing when called from PictoBlox/IDE GUIs.
162
+ kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0)
163
+ else:
164
+ kwargs["start_new_session"] = True
165
+ try:
166
+ proc = subprocess.Popen(args, **kwargs)
167
+ except OSError as e:
168
+ raise ToolchainError(f"Could not start {Path(str(args[0])).name}: {e.strerror or e}") from e
169
+ try:
170
+ out, err = proc.communicate(timeout=timeout)
171
+ except subprocess.TimeoutExpired as e:
172
+ _kill_process_tree(proc)
173
+ try:
174
+ proc.communicate(timeout=5)
175
+ except Exception:
176
+ pass
177
+ raise ToolchainError(f"External tool timed out after {timeout:g} seconds.") from e
178
+ return subprocess.CompletedProcess(args, proc.returncode, out or "", err or "")
179
+
180
+
181
+ def env_seconds(name: str, default: float) -> float:
182
+ """Read a timeout override without crashing on a malformed value."""
183
+ raw = os.environ.get(name)
184
+ if not raw:
185
+ return default
186
+ try:
187
+ value = float(raw)
188
+ except ValueError:
189
+ return default
190
+ return value if value > 0 else default
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class EnvironmentFingerprint:
195
+ python_executable: str
196
+ python_version: str
197
+ platform: str
198
+ package_version: str
199
+ arduino_cli: str | None
200
+ arduino_cli_mtime_ns: int | None
201
+ avr_core_marker: str | None
202
+ library_markers: tuple[str, ...]
203
+ integrity_markers: tuple[str, ...] = ()
204
+
205
+ def digest(self) -> str:
206
+ payload = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
207
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
208
+
209
+
210
+ def _marker(path: Path | None) -> str | None:
211
+ if not path or not path.exists():
212
+ return None
213
+ try:
214
+ st = path.stat()
215
+ return f"{path.resolve()}|{st.st_size}|{st.st_mtime_ns}"
216
+ except OSError:
217
+ return str(path)
218
+
219
+
220
+ def _sha256_file(path: Path) -> str:
221
+ h = hashlib.sha256()
222
+ with path.open("rb") as f:
223
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
224
+ h.update(chunk)
225
+ return h.hexdigest()
226
+
227
+
228
+ def _content_marker(path: Path | None, *, source_only: bool = False) -> str | None:
229
+ """Content-sensitive marker used only for cache safety.
230
+
231
+ A READY cache is allowed to skip the expensive test compile only for Barmaja's
232
+ pinned runtime. For its core/libraries we hash compilation-relevant source and
233
+ metadata, and for tool ``bin`` directories we hash every file. This deliberately
234
+ catches replacements that preserve file timestamps or sizes.
235
+ """
236
+ if not path or not path.exists():
237
+ return None
238
+ try:
239
+ path = path.resolve()
240
+ if path.is_file():
241
+ return f"sha256:{path}:{_sha256_file(path)}"
242
+ allowed = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".s", ".S", ".txt", ".properties", ".json", ".conf", ".cfg", ".yaml", ".yml"}
243
+ h = hashlib.sha256()
244
+ count = 0
245
+ for child in sorted((p for p in path.rglob("*") if p.is_file()), key=lambda p: str(p.relative_to(path)).lower()):
246
+ if source_only and child.suffix not in allowed and child.name not in {"platform.txt", "boards.txt", "programmers.txt"}:
247
+ continue
248
+ rel = child.relative_to(path).as_posix().encode("utf-8", "surrogatepass")
249
+ h.update(len(rel).to_bytes(4, "big")); h.update(rel)
250
+ h.update(bytes.fromhex(_sha256_file(child)))
251
+ count += 1
252
+ return f"tree-sha256:{path}:{count}:{h.hexdigest()}"
253
+ except OSError:
254
+ return None
255
+
256
+
257
+ def _bundled_integrity_markers(cli_path: Path | None, libraries: Iterable[Path]) -> tuple[str, ...]:
258
+ """Strong markers for the immutable Full-Pack runtime; empty for other CLIs."""
259
+ if not cli_path or not cli_path.exists():
260
+ return ()
261
+ try:
262
+ runtime = (ensure_runtime_dirs()["root"] / "runtime").resolve()
263
+ cli_resolved = cli_path.resolve()
264
+ if cli_resolved.parent != runtime:
265
+ return ()
266
+ except OSError:
267
+ return ()
268
+
269
+ paths: list[tuple[Path, bool]] = [(cli_resolved, False)]
270
+ cfg = runtime / "arduino-cli.yaml"
271
+ if cfg.exists():
272
+ paths.append((cfg, False))
273
+
274
+ # Pinned AVR core source/configuration.
275
+ core_root = runtime / "arduino-data" / "packages" / "arduino" / "hardware" / "avr"
276
+ if core_root.exists():
277
+ for version_dir in sorted(p for p in core_root.iterdir() if p.is_dir()):
278
+ paths.append((version_dir, True))
279
+
280
+ # Compiler and upload executables. Hash the complete bin directories so a
281
+ # damaged helper (cc1plus/as/ld/objcopy/size/avrdude) cannot hide behind cache.
282
+ tools_root = runtime / "arduino-data" / "packages" / "arduino" / "tools"
283
+ if tools_root.exists():
284
+ for family in ("avr-gcc", "avrdude"):
285
+ fam = tools_root / family
286
+ if fam.exists():
287
+ for version_dir in sorted(p for p in fam.iterdir() if p.is_dir()):
288
+ bin_dir = version_dir / "bin"
289
+ paths.append((bin_dir if bin_dir.exists() else version_dir, False))
290
+
291
+ # Arduino CLI builtin discovery/monitor tools are required for fully-offline
292
+ # board detection/monitoring. Include them in the pinned-runtime fingerprint
293
+ # so quarantine/corruption cannot be hidden by a stale READY cache.
294
+ builtin_root = runtime / "arduino-data" / "packages" / "builtin" / "tools"
295
+ if builtin_root.exists():
296
+ for family in ("serial-discovery", "mdns-discovery", "dfu-discovery", "serial-monitor"):
297
+ fam = builtin_root / family
298
+ if fam.exists():
299
+ for version_dir in sorted(p for p in fam.iterdir() if p.is_dir()):
300
+ paths.append((version_dir, False))
301
+
302
+ # Private libraries are authoritative because compile uses --library. Hash
303
+ # their source trees, not merely library.properties.
304
+ roots: set[Path] = set()
305
+ for marker in libraries:
306
+ p = Path(marker)
307
+ root = p.parent if p.name == "library.properties" else p
308
+ try:
309
+ roots.add(root.resolve())
310
+ except OSError:
311
+ roots.add(root)
312
+ for root in sorted(roots, key=lambda p: str(p).lower()):
313
+ paths.append((root, True))
314
+
315
+ result=[]
316
+ for p, source_only in paths:
317
+ marker = _content_marker(p, source_only=source_only)
318
+ if marker:
319
+ result.append(marker)
320
+ return tuple(result)
321
+
322
+
323
+ def make_fingerprint(arduino_cli: str | None, core_marker: Path | None = None, libraries: Iterable[Path] = ()) -> EnvironmentFingerprint:
324
+ cli_path = Path(arduino_cli) if arduino_cli else None
325
+ mtime = None
326
+ if cli_path and cli_path.exists():
327
+ try:
328
+ mtime = cli_path.stat().st_mtime_ns
329
+ except OSError:
330
+ pass
331
+ libs = tuple(libraries)
332
+ return EnvironmentFingerprint(
333
+ python_executable=str(Path(sys.executable).resolve()),
334
+ python_version=".".join(map(str, sys.version_info[:3])),
335
+ platform=f"{platform.system()}-{platform.machine()}",
336
+ package_version=package_version(),
337
+ arduino_cli=str(cli_path.resolve()) if cli_path and cli_path.exists() else arduino_cli,
338
+ arduino_cli_mtime_ns=mtime,
339
+ avr_core_marker=_marker(core_marker),
340
+ library_markers=tuple(sorted(filter(None, (_marker(p) for p in libs)))),
341
+ integrity_markers=_bundled_integrity_markers(cli_path, libs),
342
+ )
343
+
344
+
345
+ def cache_file() -> Path:
346
+ p = ensure_runtime_dirs()["cache"] / "environment.json"
347
+ p.parent.mkdir(parents=True, exist_ok=True)
348
+ return p
349
+
350
+
351
+ def load_cache() -> dict | None:
352
+ p = cache_file()
353
+ if not p.exists():
354
+ return None
355
+ try:
356
+ data = json.loads(p.read_text(encoding="utf-8"))
357
+ if data.get("schema") != CACHE_SCHEMA:
358
+ return None
359
+ return data
360
+ except Exception:
361
+ return None
362
+
363
+
364
+ def save_cache(fingerprint: EnvironmentFingerprint, *, quick_fingerprint: EnvironmentFingerprint | None = None, status: str = "READY", source: str | None = None) -> None:
365
+ payload = {
366
+ "schema": CACHE_SCHEMA,
367
+ "status": status,
368
+ "fingerprint": fingerprint.digest(),
369
+ "quick_fingerprint": (quick_fingerprint or fingerprint).digest(),
370
+ "source": source,
371
+ }
372
+ p = cache_file()
373
+ tmp = p.with_suffix(".tmp")
374
+ tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
375
+ tmp.replace(p)
376
+
377
+
378
+ def invalidate_cache() -> None:
379
+ try:
380
+ cache_file().unlink(missing_ok=True)
381
+ except OSError:
382
+ pass
383
+
384
+
385
+ def find_normal_arduino_cli() -> str | None:
386
+ override = os.environ.get("BARMAJA_ARDUINO_CLI")
387
+ if override and Path(override).exists():
388
+ return str(Path(override).resolve())
389
+ found = shutil.which("arduino-cli")
390
+ if found:
391
+ return found
392
+ candidates: list[Path] = []
393
+ if os.name == "nt":
394
+ ide_cli = Path("Arduino IDE") / "resources" / "app" / "lib" / "backend" / "resources" / "arduino-cli.exe"
395
+ local = os.environ.get("LOCALAPPDATA")
396
+ if local:
397
+ # Arduino IDE 2 "install for me only" (default) location.
398
+ candidates.append(Path(local) / "Programs" / ide_cli)
399
+ for env_name in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"):
400
+ base = os.environ.get(env_name)
401
+ if base:
402
+ candidates.extend([
403
+ Path(base) / ide_cli,
404
+ Path(base) / "Arduino15" / "arduino-cli.exe",
405
+ ])
406
+ else:
407
+ candidates.extend([Path("/usr/bin/arduino-cli"), Path("/usr/local/bin/arduino-cli")])
408
+ for p in candidates:
409
+ if p.exists():
410
+ return str(p.resolve())
411
+ return None
412
+
413
+
414
+ def pictoblox_root_candidates() -> tuple[Path, ...]:
415
+ roots: list[Path] = []
416
+ explicit = os.environ.get("BARMAJA_PICTOBLOX_ROOT")
417
+ if explicit:
418
+ p = Path(explicit).expanduser()
419
+ if p.exists(): roots.append(p.resolve())
420
+ if os.name == "nt":
421
+ for env_name in ("PROGRAMFILES", "PROGRAMFILES(X86)"):
422
+ base = os.environ.get(env_name)
423
+ if base:
424
+ p = Path(base) / "PictoBlox"
425
+ if p.exists(): roots.append(p.resolve())
426
+ unique=[]; seen=set()
427
+ for p in roots:
428
+ k=os.path.normcase(str(p))
429
+ if k not in seen: unique.append(p); seen.add(k)
430
+ return tuple(unique)
431
+
432
+
433
+ def find_pictoblox_arduino_cli() -> str | None:
434
+ """Return a PictoBlox-bundled Arduino CLI only when an actual executable is found.
435
+
436
+ We intentionally do not infer compatibility from PictoBlox being installed. The
437
+ caller must still run version/core/library/capability checks before reuse.
438
+ """
439
+ explicit = os.environ.get("BARMAJA_PICTOBLOX_ARDUINO_CLI")
440
+ if explicit and Path(explicit).exists():
441
+ return str(Path(explicit).resolve())
442
+ rels = (
443
+ Path("resources") / "arduino-cli.exe",
444
+ Path("resources") / "app" / "arduino-cli.exe",
445
+ Path("resources") / "backend" / "arduino-cli.exe",
446
+ Path("resources") / "arduino" / "arduino-cli.exe",
447
+ )
448
+ for root in pictoblox_root_candidates():
449
+ for rel in rels:
450
+ p = root / rel
451
+ if p.exists(): return str(p.resolve())
452
+ return None
453
+
454
+
455
+ def windows_documents_dir() -> Path | None:
456
+ """The real Documents folder (Known Folder API), including OneDrive/GPO redirection."""
457
+ if os.name != "nt":
458
+ return None
459
+ try:
460
+ import ctypes
461
+ import uuid
462
+ from ctypes import wintypes
463
+
464
+ class _GUID(ctypes.Structure):
465
+ _fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD), ("Data3", wintypes.WORD), ("Data4", ctypes.c_ubyte * 8)]
466
+
467
+ guid = _GUID()
468
+ ctypes.memmove(ctypes.byref(guid), uuid.UUID("{FDD39AD0-238F-46AF-ADB4-6C85480369C7}").bytes_le, 16)
469
+ out = ctypes.c_wchar_p()
470
+ hr = ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(out))
471
+ try:
472
+ if hr != 0 or not out.value:
473
+ return None
474
+ return Path(out.value)
475
+ finally:
476
+ ctypes.windll.ole32.CoTaskMemFree(out)
477
+ except Exception:
478
+ return None
479
+
480
+
481
+ def other_windows_documents_candidates(users_root: str | Path | None = None) -> tuple[Path, ...]:
482
+ """Best-effort Documents locations for other local Windows user profiles.
483
+
484
+ This supplements the Known Folder API (current user only) for school PCs where
485
+ an administrator installs PictoBlox support for several student accounts. We
486
+ only return directories that already exist; the caller still decides whether a
487
+ PictoBlox module target exists beneath them.
488
+ """
489
+ if users_root is None:
490
+ override = os.environ.get("BARMAJA_WINDOWS_USERS_ROOT")
491
+ if override:
492
+ users = Path(override)
493
+ else:
494
+ drive = os.environ.get("SystemDrive", "C:")
495
+ users = Path(drive + "\\Users")
496
+ else:
497
+ users = Path(users_root)
498
+ found: list[Path] = []
499
+ try:
500
+ profiles = [p for p in users.iterdir() if p.is_dir()]
501
+ except OSError:
502
+ return ()
503
+ for profile in profiles:
504
+ direct = profile / "Documents"
505
+ if direct.is_dir():
506
+ try: found.append(direct.resolve())
507
+ except OSError: found.append(direct)
508
+ try:
509
+ children = [p for p in profile.iterdir() if p.is_dir() and p.name.lower().startswith("onedrive")]
510
+ except OSError:
511
+ children = []
512
+ for cloud in children:
513
+ docs = cloud / "Documents"
514
+ if docs.is_dir():
515
+ try: found.append(docs.resolve())
516
+ except OSError: found.append(docs)
517
+ unique=[]; seen=set()
518
+ for p in found:
519
+ key=os.path.normcase(str(p))
520
+ if key not in seen:
521
+ unique.append(p); seen.add(key)
522
+ return tuple(unique)
523
+
524
+
525
+ def documents_dir_candidates() -> tuple[Path, ...]:
526
+ candidates: list[Path] = []
527
+ known = windows_documents_dir()
528
+ if known:
529
+ candidates.append(known)
530
+ for env_name in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
531
+ base = os.environ.get(env_name)
532
+ if base:
533
+ candidates.append(Path(base) / "Documents")
534
+ userprofile = os.environ.get("USERPROFILE")
535
+ if userprofile:
536
+ candidates.append(Path(userprofile) / "Documents")
537
+ candidates.append(Path.home() / "Documents")
538
+ if os.name == "nt":
539
+ candidates.extend(other_windows_documents_candidates())
540
+ unique=[]; seen=set()
541
+ for p in candidates:
542
+ try: p=p.resolve()
543
+ except OSError: pass
544
+ key=os.path.normcase(str(p))
545
+ if key not in seen:
546
+ unique.append(p); seen.add(key)
547
+ return tuple(unique)
548
+
549
+
550
+ def _running_pictoblox_modules_dir() -> Path | None:
551
+ """When imported from PictoBlox, the folder this package was loaded from."""
552
+ here = Path(__file__).resolve().parent.parent
553
+ return here if here.name.lower() == "python_modules" else None
554
+
555
+
556
+ def pictoblox_module_dirs() -> tuple[Path, ...]:
557
+ found: list[Path] = []
558
+ override = os.environ.get("BARMAJA_PICTOBLOX_MODULES")
559
+ if override:
560
+ p = Path(override).expanduser()
561
+ # An explicit target is authoritative even before python_modules exists;
562
+ # the offline installer is responsible for creating it.
563
+ try: found.append(p.resolve())
564
+ except OSError: found.append(p)
565
+ running = _running_pictoblox_modules_dir()
566
+ if running is not None:
567
+ found.append(running)
568
+ for docs in documents_dir_candidates():
569
+ for name in ("Pictoblox", "PictoBlox"):
570
+ p = docs / name / "python_modules"
571
+ try:
572
+ # Existing python_modules is ideal; an existing PictoBlox folder
573
+ # is also enough for the offline installer to create python_modules.
574
+ if p.exists() or p.parent.exists():
575
+ found.append(p.resolve())
576
+ except OSError:
577
+ continue
578
+ unique = []
579
+ seen = set()
580
+ for p in found:
581
+ key = os.path.normcase(str(p))
582
+ if key not in seen:
583
+ unique.append(p); seen.add(key)
584
+ return tuple(unique)
585
+
586
+
587
+ def installed_package_version(package_dir: Path) -> str | None:
588
+ """Read __version__ from an installed Barmaja_PyArduino copy without importing it."""
589
+ import re
590
+ try:
591
+ text = (package_dir / "__init__.py").read_text(encoding="utf-8", errors="replace")
592
+ except OSError:
593
+ return None
594
+ m = re.search(r"^__version__\s*=\s*[\"']([^\"']+)[\"']", text, re.M)
595
+ return m.group(1) if m else None