7 Commits
Author SHA1 Message Date
kkallas 3fe45fa4d5 Merge pull request 'Feature/updater' (#2) from feature/updater into main
Reviewed-on: #2
2026-09-28 19:47:15 +02:00
kkallasandClaude Opus 5.5 1ab8af8fe2 README: fix two Windows paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:44:56 +02:00
kkallasandClaude Opus 5.5 bfbbd537d5 Updater messages without "!", README and changelog for v0.9
cmd drops "!" from messages when delayed expansion is on, so errors
read "ERROR:". README describes the two files per system and the
first install from a release zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:43:50 +02:00
kkallasandClaude Opus 5.5 a33736742a Technician updater and one shared Python environment (v0.9.0)
update_windows.bat / update_macos.command: fetch release tags from Gitea,
move the folder to the newest vX.Y (git downloads only what changed),
connect a folder unpacked from a release zip on first run, install git/uv
when missing and sync the Python libraries. Developer copies on a branch
are left alone.

run_* and update_* share one environment (KotaTechnicianTool/venv)
instead of one per version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:42:25 +02:00
kkallas 724c2d62f9 Merge pull request 'README: install and update from Gitea instead of Google Drive folders' (#1) from docs/readme-gitea into main
Reviewed-on: #1
2026-09-28 19:36:35 +02:00
kkallasandClaude Opus 5.5 43f4c51aa4 README: install and update from Gitea instead of Google Drive folders
Describes the zip from Releases and git clone/pull, the macOS
executable-bit note for zips, and the branch/PR/tag workflow for the team.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:34:01 +02:00
kkallas bfa7edce6f Technician tool v0.8
Imported from the Google Drive folder 004-technician_tool/v0.8.
2026-09-28 19:19:55 +02:00
16 changed files with 634 additions and 297 deletions

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
# KOTA Sensorbox Technician Tool
Diagnoza sensorboxa przez USB, wgrywanie firmware z folderu `firmware/` i przygotowanie karty SD.
Działa na Windows 10/11 i macOS 13 (Ventura) lub nowszym, na Macach z Intelem i Apple Silicon.
Repozytorium: https://gitea.kota-app.com/kkallas/kota-technician-tool (dostęp nadaje właściciel repozytorium).
## Dla technika: dwa pliki
| System | Aktualizacja | Uruchomienie |
|---|---|---|
| Windows | `update_windows.bat` | `run_windows.bat` |
| macOS | `update_macos.command` | `run_macos.command` |
- **Aktualizacja** sprawdza na Gitea najnowszą wydaną wersję i pobiera tylko to, co się zmieniło
(pliki programu, firmware i potrzebne biblioteki). Uruchom ją, gdy firmware team ogłosi nową wersję,
albo po prostu przed wyjazdem do klienta. Pokazuje, co się zmieniło.
- **Uruchomienie** startuje program. Nie łączy się z Gitea, działa też bez internetu (po pierwszej aktualizacji).
Nie trzymaj folderu narzędzia w Google Drive ani OneDrive.
## Pierwsza instalacja (raz na komputer)
1. Na Gitea otwórz **Releases** i przy najnowszej wersji pobierz **Source Code (ZIP)**.
2. Rozpakuj do zwykłego folderu, np. `Dokumenty\KOTA\technician-tool` (macOS: `~/Documents/KOTA/technician-tool`).
3. Uruchom plik aktualizacji dla swojego systemu. Za pierwszym razem:
- połączy folder z Gitea i zapyta o login do Gitea (hasło lub token); system je zapamięta,
- zainstaluje brakujące narzędzia za Twoją zgodą: Git (Windows: przez winget; macOS: Apple Command Line Tools
- po ich instalacji uruchom aktualizację jeszcze raz) oraz `uv` (menedżer Pythona, tylko dla bieżącego
użytkownika),
- pobierze biblioteki Pythona (1–2 minuty, potrzebny internet).
4. Uruchom program plikiem `run_...`.
**macOS:** jeśli Finder odpowie, że pliku nie można uruchomić (zdarza się po rozpakowaniu zip), otwórz Terminal,
wpisz `bash ` (ze spacją), przeciągnij `update_macos.command` do okna i naciśnij Enter. Po pierwszej aktualizacji
oba pliki `.command` dają się już otwierać dwuklikiem.
Zamiast zip można też zrobić `git clone https://gitea.kota-app.com/kkallas/kota-technician-tool.git` i dalej
używać tych samych dwóch plików.
Konkretna starsza wersja (np. do serwisu nietypowego boxa): w folderze narzędzia `git switch --detach v0.8`;
kolejna aktualizacja wraca do najnowszej.
Wszystkie wersje używają jednego środowiska Pythona poza folderem narzędzia:
`%LOCALAPPDATA%\KotaTechnicianTool\venv` (Windows), `~/Library/Caches/KotaTechnicianTool/venv` (macOS).
Stare środowiska `venv-0.x` z poprzednich wersji można usunąć.
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
## Port sensorboxa
- Windows: `COMx - Sensorbox (native USB) [MAC]`
- macOS: `/dev/cu.usbmodemXXXX - Sensorbox (native USB) [MAC]`
Kabel USB musi przenosić dane (nie tylko ładowanie). Przy restartach z komunikatem o brownout użyj innego portu
lub zasilacza.
## Firmware
Wgrać można tylko pliki `.bin` z folderu `firmware/`. Lista pokazuje datę kompilacji zapisaną w pliku,
najnowszy build jest na górze. Nowy build: wrzuć plik do `firmware/` i kliknij **Refresh list**.
## Dla zespołu
- Praca nad zmianą: gałąź (`git switch -c feature/<nazwa>`) → commity z przechodzącymi testami →
`git push -u origin feature/<nazwa>` → pull request na Gitea → merge do `main`.
- Wydanie wersji: numer w `pyproject.toml`, `kota_tech/__init__.py` i `uv.lock`, tag `vX.Y`
(`git tag -a vX.Y -m "Technician tool vX.Y"`, `git push origin vX.Y`), opis w **Releases** na Gitea.
Aktualizacja techników bierze **tylko tagi `v*`** – zmiany w `main` bez tagu do nich nie trafiają.
- Nie commitujemy `ENV.TXT` z prawdziwym tokenem ani haseł Wi-Fi.
- Testy:
```
uv run --frozen pytest -q
```
Zmiany w wersjach: `docs/CHANGES-v0.*.md`; historia v0.1–v0.8 jest w tagach gita.
+21
View File
@@ -0,0 +1,21 @@
# Technician tool v0.8 - changes
## Windows + macOS
- Dependencies verified to install on macOS arm64 and x86_64 (uv.lock: PySide6 6.11 universal2 wheels,
requires macOS 13+); Windows unchanged.
- SD card check: macOS `diskutil` reports FAT32 partitions as `DOS_FAT_32` / `Windows_FAT_32`; these are now
accepted (previously a correct card was reported as "not FAT32" on a Mac).
- Unit tests for the macOS serial-port filter (`/dev/cu.*` only) and FAT32 names.
- README.md with launch steps for both systems (incl. the Google Drive executable-bit workaround on macOS);
docs/mac_agent_command.txt updated for a v0.8 test run on a Mac.
## Diagnosis
- Harmless Wi-Fi start-up errors are ignored (rule `benign.wifi_start`): `wifi:sta is connecting, return error`,
`esp_wifi_connect() failed: ESP_ERR_WIFI_CONN`, `Failed to get configured network when updating network
status` (unpaired box).
- Brown-out resets are detected (`E BOD: Brownout detector was triggered`, ROM reports RTC_SW_SYS_RST).
- Service report note = the Diagnosis panel as text (LED, firmware, box self-report, start-up progress with
times, findings with "Seen Nx, first at ... s, last at ... s" and log line), shortened to fit the service
form's 5000 characters.
Not yet run on a real Mac: see docs/mac_agent_command.txt.
+9
View File
@@ -0,0 +1,9 @@
# Technician tool v0.9 - changes
- Two files per system for technicians: `update_windows.bat` / `update_macos.command` (update) and
`run_windows.bat` / `run_macos.command` (start).
- Update: checks Gitea for the newest release tag `vX.Y`, downloads only what changed (git), shows the list of
changes, protects files edited by hand (asks first), connects a folder unpacked from a release zip on first
run, installs git / uv when missing (asks first) and brings the Python libraries up to date.
- One shared Python environment for all versions: `KotaTechnicianTool/venv` (was `venv-0.x` per version).
- A developer copy on a branch is left alone by the update script (use `git pull`).
+35 -30
View File
@@ -1,30 +1,35 @@
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is
004-technician_tool/v0.5 (a Python/PySide6 desktop app; it has only been tested on Windows so far).
Rules:
- Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin.
- Do not push anything to Git or GitLab. This is a local demo.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
Before formatting, show me the exact disk (identifier, name, size, current files).
- Do not change code without telling me. Report bugs with evidence (commands and output).
Tests, in order. Report pass/fail with evidence for each:
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q`
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the
reset and whether the tool reconnects.
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself,
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output.
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT".
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical
to the template, and eject the card.
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine
if the page itself cannot prefill yet).
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
suggested fixes (file:line).
You are testing the KOTA sensorbox technician tool v0.8 on macOS. The current folder is
004-technician_tool/v0.8 (a Python/PySide6 desktop app; tested on Windows 11, macOS paths are covered by
unit tests but not yet run on a real Mac).
Rules:
- Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
- Do not push anything to Git or GitLab. This is a local demo.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
Before formatting, show me the exact disk (identifier, name, size, current files).
- Do not change code without telling me. Report bugs with evidence (commands and output).
Tests, in order. Report pass/fail with evidence for each:
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
(expect 172 passed).
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
"ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
section and the LED with its pattern. Check whether the port disappears after the reset and whether the
tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
7. SD card tab: the card reader and card are listed (internal disks must NOT be listed). "Check card" must NOT
report a wrong file system for a FAT32 card. After my OK, "Format card and write ENV.TXT" must use
`diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
8. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=...; the note in
the form must match the Diagnosis panel (self-report, start-up progress with times, findings with
"Seen Nx, first at ... s").
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
suggested fixes (file:line).
+1 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool."""
__version__ = "0.7.0"
__version__ = "0.9.0"
+8
View File
@@ -137,6 +137,14 @@ id = "benign.zcl"
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
severity = "ignore"
[[rule]]
id = "benign.wifi_start"
# Harmless Wi-Fi noise seen on every 1.0.16 boot: Matter asks for a connect
# while the station is already connecting, and an unpaired box polls a
# network it does not have yet (matter.not_commissioned covers that case).
pattern = 'wifi:sta is connecting, return error|esp_wifi_connect\(\) failed: ESP_ERR_WIFI_CONN|Failed to get configured network when updating network status'
severity = "ignore"
[[rule]]
id = "benign.connect_chain"
# Transport-level echo of a failure that a more specific rule reports.
+261 -260
View File
@@ -1,260 +1,261 @@
"""Format an SD card for the sensorbox and write / check ENV.TXT.
Card format required by the firmware: MBR partition table, FAT32 (its FAT
driver has no exFAT and no long file names), ENV.TXT in the root.
Windows: formatting needs administrator rights, so kota_tech.sd.win_format
runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
Windows drive) right before erasing it, so a card swapped after the
technician confirmed is not touched, and it writes MBR + FAT32 itself
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
"""
from __future__ import annotations
import json
import os
import plistlib
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from .disks import Disk
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
VOLUME_LABEL = "KOTA"
@dataclass
class Outcome:
ok: bool
message: str
mount: str = ""
log: list[str] = field(default_factory=list)
@dataclass
class CardReport:
mount: str
filesystem: str
env: EnvCheck | None
buffered_lines: int | None # measurements waiting on the card for upload
problems: list[str]
# ----------------------------------------------------------------- Windows
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
def _workdir() -> Path:
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a
# file written there by the ELEVATED script is then unreadable for this
# (non-elevated) process. A normally inherited ACL works for both.
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
work.mkdir(parents=True, exist_ok=False)
return work
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
"""(program, parameters, working dir) for the elevated helper."""
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
f'--label {VOLUME_LABEL} --result "{result}"'
return sys.executable, params, str(root)
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
"""ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
import ctypes
from ctypes import wintypes
class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
if not shell32.ShellExecuteExW(ctypes.byref(info)):
return None # ERROR_CANCELLED (1223) or failure
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
code = wintypes.DWORD()
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
k32.CloseHandle(info.hProcess)
return code.value
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
result = _workdir() / "result.json"
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
t = time.perf_counter()
code = _run_elevated(*win_format_command(disk, result))
if code is None:
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
try:
res = json.loads(result.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
"Check the card with 'Check card'.", log=log)
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
def _eject_windows(mount: str) -> bool:
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does.
Not Shell.Application InvokeVerb('Eject'): it fails silently because the
verb is named 'E&ject' (and is localised on non-English Windows).
"""
import ctypes
from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateFileW.restype = wintypes.HANDLE
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808
handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
if handle in (None, wintypes.HANDLE(-1).value):
return False
ret = wintypes.DWORD()
def ioctl(code: int, buf=None, size: int = 0) -> bool:
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
try:
k32.FlushFileBuffers(handle)
for _ in range(10): # Explorer/indexer may hold it briefly
if ioctl(FSCTL_LOCK_VOLUME):
break
time.sleep(0.5)
else:
return False
if not ioctl(FSCTL_DISMOUNT_VOLUME):
return False
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
return True
finally:
k32.CloseHandle(handle)
# ------------------------------------------------------------------- macOS
def _format_macos(disk: Disk, log: list[str]) -> Outcome:
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id],
capture_output=True, text=True, timeout=600)
log += [l for l in proc.stdout.splitlines() if l.strip()]
if proc.returncode != 0:
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
+ " (is the card's write-protect switch on?)", log=log)
part = f"{disk.id}s1"
for _ in range(20): # wait for the volume to mount
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
if info.get("MountPoint"):
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
time.sleep(0.5)
return Outcome(False, "Formatted, but the card did not mount.", log=log)
def _eject_macos(disk_id: str) -> bool:
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
# ------------------------------------------------------------------ common
def write_env(mount: str, env: EnvConfig) -> Outcome:
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back."""
problems = [i.text for i in env.problems() if i.level == "error"]
if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems))
root = Path(mount)
if not root.is_dir():
return Outcome(False, f"Card not found at {mount}.")
old = find_case_insensitive(root, ENV_NAME)
if old is not None and old.name != ENV_NAME:
old.unlink() # replace env.txt / Env.txt variants
target = root / ENV_NAME
data = env.render()
with open(target, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
if target.read_bytes() != data:
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
check = parse_env(target.read_bytes())
if not check.ok:
return Outcome(False, "ENV.TXT was written but does not validate: "
+ " ".join(i.text for i in check.issues if i.level == "error"))
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount)
def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome:
problems = [i.text for i in env.problems() if i.level == "error"]
if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems))
log: list[str] = []
if sys.platform == "win32":
fmt = _format_windows(disk, log)
elif sys.platform == "darwin":
fmt = _format_macos(disk, log)
else:
return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
if not fmt.ok:
return fmt
wr = write_env(fmt.mount, env)
log.append(wr.message)
if not wr.ok:
return Outcome(False, wr.message, fmt.mount, log)
if eject_card:
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
"Could not eject the card automatically - use 'Safely remove' before pulling it out.")
return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
def eject(mount: str, disk: Disk | None = None) -> bool:
"""True if the card was dismounted and can be pulled out safely."""
if sys.platform == "win32":
return _eject_windows(mount)
if sys.platform == "darwin" and disk is not None:
return _eject_macos(disk.id)
return False
def check_card(mount: str, filesystem: str = "") -> CardReport:
"""What the box would find on this card (values masked in the UI)."""
root = Path(mount)
problems: list[str] = []
fs = filesystem.upper()
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"):
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
env_file = find_case_insensitive(root, ENV_NAME)
env = parse_env(env_file.read_bytes()) if env_file else None
if env_file is None:
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
elif len(env_file.name.split(".")[0]) > 8:
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
buf = find_case_insensitive(root, BUFFER_NAME)
lines = None
if buf is not None:
with open(buf, "rb") as fh:
lines = sum(1 for ln in fh if ln.strip())
return CardReport(mount, filesystem, env, lines, problems)
"""Format an SD card for the sensorbox and write / check ENV.TXT.
Card format required by the firmware: MBR partition table, FAT32 (its FAT
driver has no exFAT and no long file names), ENV.TXT in the root.
Windows: formatting needs administrator rights, so kota_tech.sd.win_format
runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
Windows drive) right before erasing it, so a card swapped after the
technician confirmed is not touched, and it writes MBR + FAT32 itself
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
"""
from __future__ import annotations
import json
import os
import plistlib
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from .disks import Disk
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
VOLUME_LABEL = "KOTA"
@dataclass
class Outcome:
ok: bool
message: str
mount: str = ""
log: list[str] = field(default_factory=list)
@dataclass
class CardReport:
mount: str
filesystem: str
env: EnvCheck | None
buffered_lines: int | None # measurements waiting on the card for upload
problems: list[str]
# ----------------------------------------------------------------- Windows
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
def _workdir() -> Path:
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a
# file written there by the ELEVATED script is then unreadable for this
# (non-elevated) process. A normally inherited ACL works for both.
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
work.mkdir(parents=True, exist_ok=False)
return work
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
"""(program, parameters, working dir) for the elevated helper."""
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
f'--label {VOLUME_LABEL} --result "{result}"'
return sys.executable, params, str(root)
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
"""ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
import ctypes
from ctypes import wintypes
class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
if not shell32.ShellExecuteExW(ctypes.byref(info)):
return None # ERROR_CANCELLED (1223) or failure
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
code = wintypes.DWORD()
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
k32.CloseHandle(info.hProcess)
return code.value
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
result = _workdir() / "result.json"
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
t = time.perf_counter()
code = _run_elevated(*win_format_command(disk, result))
if code is None:
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
try:
res = json.loads(result.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
"Check the card with 'Check card'.", log=log)
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
def _eject_windows(mount: str) -> bool:
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does.
Not Shell.Application InvokeVerb('Eject'): it fails silently because the
verb is named 'E&ject' (and is localised on non-English Windows).
"""
import ctypes
from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateFileW.restype = wintypes.HANDLE
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808
handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
if handle in (None, wintypes.HANDLE(-1).value):
return False
ret = wintypes.DWORD()
def ioctl(code: int, buf=None, size: int = 0) -> bool:
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
try:
k32.FlushFileBuffers(handle)
for _ in range(10): # Explorer/indexer may hold it briefly
if ioctl(FSCTL_LOCK_VOLUME):
break
time.sleep(0.5)
else:
return False
if not ioctl(FSCTL_DISMOUNT_VOLUME):
return False
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
return True
finally:
k32.CloseHandle(handle)
# ------------------------------------------------------------------- macOS
def _format_macos(disk: Disk, log: list[str]) -> Outcome:
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id],
capture_output=True, text=True, timeout=600)
log += [l for l in proc.stdout.splitlines() if l.strip()]
if proc.returncode != 0:
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
+ " (is the card's write-protect switch on?)", log=log)
part = f"{disk.id}s1"
for _ in range(20): # wait for the volume to mount
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
if info.get("MountPoint"):
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
time.sleep(0.5)
return Outcome(False, "Formatted, but the card did not mount.", log=log)
def _eject_macos(disk_id: str) -> bool:
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
# ------------------------------------------------------------------ common
def write_env(mount: str, env: EnvConfig) -> Outcome:
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back."""
problems = [i.text for i in env.problems() if i.level == "error"]
if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems))
root = Path(mount)
if not root.is_dir():
return Outcome(False, f"Card not found at {mount}.")
old = find_case_insensitive(root, ENV_NAME)
if old is not None and old.name != ENV_NAME:
old.unlink() # replace env.txt / Env.txt variants
target = root / ENV_NAME
data = env.render()
with open(target, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
if target.read_bytes() != data:
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
check = parse_env(target.read_bytes())
if not check.ok:
return Outcome(False, "ENV.TXT was written but does not validate: "
+ " ".join(i.text for i in check.issues if i.level == "error"))
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount)
def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome:
problems = [i.text for i in env.problems() if i.level == "error"]
if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems))
log: list[str] = []
if sys.platform == "win32":
fmt = _format_windows(disk, log)
elif sys.platform == "darwin":
fmt = _format_macos(disk, log)
else:
return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
if not fmt.ok:
return fmt
wr = write_env(fmt.mount, env)
log.append(wr.message)
if not wr.ok:
return Outcome(False, wr.message, fmt.mount, log)
if eject_card:
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
"Could not eject the card automatically - use 'Safely remove' before pulling it out.")
return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
def eject(mount: str, disk: Disk | None = None) -> bool:
"""True if the card was dismounted and can be pulled out safely."""
if sys.platform == "win32":
return _eject_windows(mount)
if sys.platform == "darwin" and disk is not None:
return _eject_macos(disk.id)
return False
def check_card(mount: str, filesystem: str = "") -> CardReport:
"""What the box would find on this card (values masked in the UI)."""
root = Path(mount)
problems: list[str] = []
# Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
fs = filesystem.upper().replace("_", "").replace(" ", "")
if fs and "FAT32" not in fs and fs not in ("FAT", "MSDOS"):
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
env_file = find_case_insensitive(root, ENV_NAME)
env = parse_env(env_file.read_bytes()) if env_file else None
if env_file is None:
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
elif len(env_file.name.split(".")[0]) > 8:
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
buf = find_case_insensitive(root, BUFFER_NAME)
lines = None
if buf is not None:
with open(buf, "rb") as fh:
lines = sum(1 for ln in fh if ln.strip())
return CardReport(mount, filesystem, env, lines, problems)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kota-technician-tool"
version = "0.7.0"
version = "0.9.0"
description = "KOTA sensorbox technician diagnostic tool (demo)"
requires-python = ">=3.11,<3.14"
dependencies = [
+4 -2
View File
@@ -2,8 +2,10 @@
# Double-click in Finder, or run: bash run_macos.command
cd "$(dirname "$0")" || exit 1
# Keep the virtual environment out of the (Google Drive) tool folder.
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.7"
# If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
# bash run_macos.command (or: chmod +x run_macos.command)
# One shared Python environment for every version (update_macos.command keeps it current).
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv"
export PATH="$HOME/.local/bin:$PATH"
if ! command -v uv >/dev/null 2>&1; then
+2 -2
View File
@@ -2,8 +2,8 @@
setlocal
cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder.
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.7"
rem One shared Python environment for every version (update_windows.bat keeps it current).
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
where uv >nul 2>nul
+10
View File
@@ -247,3 +247,13 @@ def test_live_brownout_loop_is_a_power_problem():
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
assert "reboot.loop" in {c.rule_id for c in top.consequences}
assert r.verdict.startswith("Problem: Power supply")
@pytest.mark.parametrize("line", [
"E (2893) wifi:sta is connecting, return error",
"E (2892) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN",
"E (5120) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F",
])
def test_harmless_wifi_start_errors_are_not_reported(line):
_, r = run(BOOT + APP + [line])
assert r.roots == [] and "Unrecognised" not in r.to_text()
+12
View File
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
assert not bt.is_sensorbox and bt.mac is None
assert bt.label == "COM3 - Standard Serial over Bluetooth link"
def test_macos_lists_only_callout_devices(monkeypatch):
from types import SimpleNamespace
from kota_tech.serialio import ports
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
serial_number="A4:CB:8F:C9:46:F8")
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
monkeypatch.setattr(ports.sys, "platform", "darwin")
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
+11
View File
@@ -170,3 +170,14 @@ def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
assert work.parent == tmp_path / "tmp" and work.is_dir()
(work / "result.json").write_text('{"ok": true}', encoding="utf-8")
assert (work / "result.json").read_text(encoding="utf-8")
@pytest.mark.parametrize("fs", ["FAT32", "DOS_FAT_32", "Windows_FAT_32", "MS-DOS FAT32"])
def test_fat32_names_from_windows_and_macos_are_accepted(tmp_path, fs):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert not any("File system" in p for p in check_card(str(tmp_path), fs).problems)
def test_exfat_is_flagged(tmp_path):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert any("File system" in p for p in check_card(str(tmp_path), "ExFAT").problems)
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# KOTA Sensorbox Technician Tool - update to the newest released version.
# Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
# Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
# If Finder refuses to open this file, run it once as: bash update_macos.command
cd "$(dirname "$0")" || exit 1
REPO_URL="${KOTA_REPO_URL:-https://gitea.kota-app.com/kkallas/kota-technician-tool.git}"
export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-$HOME/Library/Caches/KotaTechnicianTool/venv}"
export PATH="$HOME/.local/bin:$PATH"
finish() {
[ -z "$KOTA_NO_PAUSE" ] && [ -t 0 ] && read -r -p "Press Enter to close..." _
exit "$1"
}
fail() { echo; echo "[!] $*"; echo "[!] Update did not finish."; finish 1; }
# ---- git (macOS: the Command Line Tools installer offers it) --------------
if ! git --version >/dev/null 2>&1; then
if [ "$(uname)" = Darwin ]; then
xcode-select --install >/dev/null 2>&1
fail "Git is being installed (Apple Command Line Tools). Run this again when the installer has finished."
fi
fail "Git is not installed."
fi
# ---- connect a folder unpacked from a zip ----------------------------------
if [ ! -d .git ]; then
echo "Connecting this folder to $REPO_URL ..."
git init -q && git remote add origin "$REPO_URL" || fail "Cannot set up git in this folder."
fi
# A developer copy follows a branch; it is updated with "git pull", not here.
if git rev-parse -q --verify HEAD >/dev/null 2>&1 && branch=$(git symbolic-ref -q --short HEAD); then
echo "This is a developer copy on branch $branch. Update it with: git pull"
finish 0
fi
echo "Checking for a new version..."
git fetch --quiet --force --tags origin ||
fail "Cannot reach $REPO_URL. Check the Internet connection and your Gitea login, then try again."
latest=$(git tag -l 'v*' --sort=-v:refname | head -n 1)
[ -n "$latest" ] || fail "No released version found on the server."
current=$(git describe --tags --exact-match HEAD 2>/dev/null)
if [ "$current" = "$latest" ]; then
echo "You already have the newest version $latest."
else
if [ -n "$current" ]; then
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "Some tool files in this folder were changed by hand. They will be replaced by $latest."
read -r -p "Continue? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || fail "Cancelled."
fi
echo "Updating $current -> $latest. Changes:"
git --no-pager log --oneline --no-decorate "$current..$latest"
else
echo "Installing $latest..."
fi
git -c advice.detachedHead=false checkout -q -f "$latest" || fail "Checkout of $latest failed."
echo "Now on $latest."
fi
# ---- Python libraries (only what changed) ----------------------------------
if ! command -v uv >/dev/null 2>&1; then
echo "Installing uv (Python package runner, current user only)..."
curl -LsSf https://astral.sh/uv/install.sh | sh || fail "uv installation failed. Ask the firmware team for help."
fi
echo "Checking Python libraries..."
uv sync --frozen --no-dev --python 3.12 --quiet || fail "Installing Python libraries failed."
echo
echo "Done. Start the tool with run_macos.command."
finish 0
+107
View File
@@ -0,0 +1,107 @@
@echo off
rem KOTA Sensorbox Technician Tool - update to the newest released version.
rem Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
rem Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
setlocal EnableDelayedExpansion
cd /d "%~dp0"
title KOTA Technician Tool - update
if not defined KOTA_REPO_URL set "KOTA_REPO_URL=https://gitea.kota-app.com/kkallas/kota-technician-tool.git"
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
set "PATH=%USERPROFILE%\.local\bin;%ProgramFiles%\Git\cmd;%PATH%"
rem ---- git ----------------------------------------------------------------
where git >nul 2>nul
if errorlevel 1 (
echo Git is not installed. It is needed to download updates.
choice /m "Install Git for Windows now"
if errorlevel 2 goto :fail
winget install --id Git.Git -e --source winget --accept-package-agreements
where git >nul 2>nul
if errorlevel 1 (
echo ERROR: Git installation failed. Install it from https://git-scm.com and run this again.
goto :fail
)
)
rem ---- connect a folder unpacked from a zip ---------------------------------
if not exist ".git" (
echo Connecting this folder to %KOTA_REPO_URL% ...
git init -q || goto :fail
git remote add origin "%KOTA_REPO_URL%" || goto :fail
)
rem A developer copy follows a branch; it is updated with "git pull", not here.
git rev-parse -q --verify HEAD >nul 2>nul
if not errorlevel 1 (
for /f "delims=" %%b in ('git symbolic-ref -q --short HEAD 2^>nul') do (
echo This is a developer copy on branch %%b. Update it with: git pull
goto :done
)
)
echo Checking for a new version...
git fetch --quiet --force --tags origin
if errorlevel 1 (
echo ERROR: Cannot reach %KOTA_REPO_URL%.
echo Check the Internet connection and your Gitea login, then try again.
goto :fail
)
set "LATEST="
for /f "delims=" %%t in ('git tag -l "v*" --sort^=-v:refname') do if not defined LATEST set "LATEST=%%t"
if not defined LATEST (
echo ERROR: No released version found on the server.
goto :fail
)
set "CURRENT="
for /f "delims=" %%t in ('git describe --tags --exact-match HEAD 2^>nul') do set "CURRENT=%%t"
if "%CURRENT%"=="%LATEST%" (
echo You already have the newest version %LATEST%.
goto :deps
)
if defined CURRENT (
for /f "delims=" %%s in ('git status --porcelain --untracked-files^=no') do set "DIRTY=1"
if defined DIRTY (
echo Some tool files in this folder were changed by hand. They will be replaced by %LATEST%.
choice /m "Continue"
if errorlevel 2 goto :fail
)
echo Updating %CURRENT% -^> %LATEST%. Changes:
git --no-pager log --oneline --no-decorate "%CURRENT%..%LATEST%"
) else (
echo Installing %LATEST%...
)
git -c advice.detachedHead=false checkout -q -f "%LATEST%"
if errorlevel 1 goto :fail
echo Now on %LATEST%.
rem ---- Python libraries (only what changed) ----------------------------------
:deps
where uv >nul 2>nul
if errorlevel 1 (
echo Installing uv ^(Python package runner, current user only^)...
powershell -NoProfile -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
)
where uv >nul 2>nul
if errorlevel 1 (
echo ERROR: uv installation failed. Ask the firmware team for help.
goto :fail
)
echo Checking Python libraries...
uv sync --frozen --no-dev --python 3.12 --quiet
if errorlevel 1 goto :fail
echo.
echo Done. Start the tool with run_windows.bat.
:done
if not defined KOTA_NO_PAUSE pause
exit /b 0
:fail
echo.
echo ERROR: Update did not finish.
if not defined KOTA_NO_PAUSE pause
exit /b 1
Generated
+1 -1
View File
@@ -250,7 +250,7 @@ wheels = [
[[package]]
name = "kota-technician-tool"
version = "0.7.0"
version = "0.9.0"
source = { virtual = "." }
dependencies = [
{ name = "esptool" },