Compare commits
7
Commits
v0.7
...
3fe45fa4d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe45fa4d5 | ||
|
|
1ab8af8fe2 | ||
|
|
bfbbd537d5 | ||
|
|
a33736742a | ||
|
|
724c2d62f9 | ||
|
|
43f4c51aa4 | ||
|
|
bfa7edce6f |
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -1,30 +1,35 @@
|
|||||||
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is
|
You are testing the KOTA sensorbox technician tool v0.8 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).
|
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.
|
Rules:
|
||||||
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin.
|
- Python only via uv or conda/mamba. Never use bare/system python or pip.
|
||||||
- Do not push anything to Git or GitLab. This is a local demo.
|
- Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
|
||||||
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
|
- Do not push anything to Git or GitLab. This is a local demo.
|
||||||
Before formatting, show me the exact disk (identifier, name, size, current files).
|
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
|
||||||
- Do not change code without telling me. Report bugs with evidence (commands and output).
|
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.
|
Tests, in order. Report pass/fail with evidence for each:
|
||||||
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q`
|
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
|
||||||
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
|
Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
|
||||||
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the
|
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
|
||||||
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the
|
(expect 172 passed).
|
||||||
reset and whether the tool reconnects.
|
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
|
||||||
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
|
entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
|
||||||
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself,
|
"ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
|
||||||
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output.
|
section and the LED with its pattern. Check whether the port disappears after the reset and whether the
|
||||||
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed
|
tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
|
||||||
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT".
|
4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
|
||||||
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical
|
5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
|
||||||
to the template, and eject the card.
|
6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
|
||||||
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine
|
by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
|
||||||
if the page itself cannot prefill yet).
|
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
|
||||||
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
|
`diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
|
||||||
suggested fixes (file:line).
|
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,3 +1,3 @@
|
|||||||
"""KOTA sensorbox technician tool."""
|
"""KOTA sensorbox technician tool."""
|
||||||
|
|
||||||
__version__ = "0.7.0"
|
__version__ = "0.9.0"
|
||||||
@@ -137,6 +137,14 @@ id = "benign.zcl"
|
|||||||
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
||||||
severity = "ignore"
|
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]]
|
[[rule]]
|
||||||
id = "benign.connect_chain"
|
id = "benign.connect_chain"
|
||||||
# Transport-level echo of a failure that a more specific rule reports.
|
# Transport-level echo of a failure that a more specific rule reports.
|
||||||
|
|||||||
+261
-260
@@ -1,260 +1,261 @@
|
|||||||
"""Format an SD card for the sensorbox and write / check ENV.TXT.
|
"""Format an SD card for the sensorbox and write / check ENV.TXT.
|
||||||
|
|
||||||
Card format required by the firmware: MBR partition table, FAT32 (its FAT
|
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.
|
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
|
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
|
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
|
Windows drive) right before erasing it, so a card swapped after the
|
||||||
technician confirmed is not touched, and it writes MBR + FAT32 itself
|
technician confirmed is not touched, and it writes MBR + FAT32 itself
|
||||||
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
|
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
|
||||||
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
|
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
|
||||||
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
|
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import plistlib
|
import plistlib
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .disks import Disk
|
from .disks import Disk
|
||||||
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
|
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
|
||||||
|
|
||||||
VOLUME_LABEL = "KOTA"
|
VOLUME_LABEL = "KOTA"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Outcome:
|
class Outcome:
|
||||||
ok: bool
|
ok: bool
|
||||||
message: str
|
message: str
|
||||||
mount: str = ""
|
mount: str = ""
|
||||||
log: list[str] = field(default_factory=list)
|
log: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CardReport:
|
class CardReport:
|
||||||
mount: str
|
mount: str
|
||||||
filesystem: str
|
filesystem: str
|
||||||
env: EnvCheck | None
|
env: EnvCheck | None
|
||||||
buffered_lines: int | None # measurements waiting on the card for upload
|
buffered_lines: int | None # measurements waiting on the card for upload
|
||||||
problems: list[str]
|
problems: list[str]
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- Windows
|
# ----------------------------------------------------------------- Windows
|
||||||
|
|
||||||
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
|
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
|
||||||
|
|
||||||
|
|
||||||
def _workdir() -> Path:
|
def _workdir() -> Path:
|
||||||
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a
|
# 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
|
# file written there by the ELEVATED script is then unreadable for this
|
||||||
# (non-elevated) process. A normally inherited ACL works for both.
|
# (non-elevated) process. A normally inherited ACL works for both.
|
||||||
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
|
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
|
||||||
work.mkdir(parents=True, exist_ok=False)
|
work.mkdir(parents=True, exist_ok=False)
|
||||||
return work
|
return work
|
||||||
|
|
||||||
|
|
||||||
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
|
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
|
||||||
"""(program, parameters, working dir) for the elevated helper."""
|
"""(program, parameters, working dir) for the elevated helper."""
|
||||||
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
|
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)} ' \
|
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
|
||||||
f'--label {VOLUME_LABEL} --result "{result}"'
|
f'--label {VOLUME_LABEL} --result "{result}"'
|
||||||
return sys.executable, params, str(root)
|
return sys.executable, params, str(root)
|
||||||
|
|
||||||
|
|
||||||
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
|
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."""
|
"""ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
|
||||||
import ctypes
|
import ctypes
|
||||||
from ctypes import wintypes
|
from ctypes import wintypes
|
||||||
|
|
||||||
class SHELLEXECUTEINFOW(ctypes.Structure):
|
class SHELLEXECUTEINFOW(ctypes.Structure):
|
||||||
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
|
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
|
||||||
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
|
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
|
||||||
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
|
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
|
||||||
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
|
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
|
||||||
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
|
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
|
||||||
|
|
||||||
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
|
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
|
||||||
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
|
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
|
||||||
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
|
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
|
||||||
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
||||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
if not shell32.ShellExecuteExW(ctypes.byref(info)):
|
if not shell32.ShellExecuteExW(ctypes.byref(info)):
|
||||||
return None # ERROR_CANCELLED (1223) or failure
|
return None # ERROR_CANCELLED (1223) or failure
|
||||||
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
|
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
|
||||||
code = wintypes.DWORD()
|
code = wintypes.DWORD()
|
||||||
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
|
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
|
||||||
k32.CloseHandle(info.hProcess)
|
k32.CloseHandle(info.hProcess)
|
||||||
return code.value
|
return code.value
|
||||||
|
|
||||||
|
|
||||||
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
|
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
|
||||||
result = _workdir() / "result.json"
|
result = _workdir() / "result.json"
|
||||||
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
||||||
t = time.perf_counter()
|
t = time.perf_counter()
|
||||||
code = _run_elevated(*win_format_command(disk, result))
|
code = _run_elevated(*win_format_command(disk, result))
|
||||||
if code is None:
|
if code is None:
|
||||||
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
|
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
|
||||||
try:
|
try:
|
||||||
res = json.loads(result.read_text(encoding="utf-8"))
|
res = json.loads(result.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
|
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
|
||||||
"Check the card with 'Check card'.", log=log)
|
"Check the card with 'Check card'.", log=log)
|
||||||
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
|
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})")
|
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)
|
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
|
||||||
|
|
||||||
|
|
||||||
def _eject_windows(mount: str) -> bool:
|
def _eject_windows(mount: str) -> bool:
|
||||||
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does.
|
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does.
|
||||||
|
|
||||||
Not Shell.Application InvokeVerb('Eject'): it fails silently because the
|
Not Shell.Application InvokeVerb('Eject'): it fails silently because the
|
||||||
verb is named 'E&ject' (and is localised on non-English Windows).
|
verb is named 'E&ject' (and is localised on non-English Windows).
|
||||||
"""
|
"""
|
||||||
import ctypes
|
import ctypes
|
||||||
from ctypes import wintypes
|
from ctypes import wintypes
|
||||||
|
|
||||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
k32.CreateFileW.restype = wintypes.HANDLE
|
k32.CreateFileW.restype = wintypes.HANDLE
|
||||||
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
|
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
|
||||||
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
||||||
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
||||||
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||||
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
|
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
|
||||||
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
|
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
|
||||||
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808
|
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)
|
handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
|
||||||
if handle in (None, wintypes.HANDLE(-1).value):
|
if handle in (None, wintypes.HANDLE(-1).value):
|
||||||
return False
|
return False
|
||||||
ret = wintypes.DWORD()
|
ret = wintypes.DWORD()
|
||||||
|
|
||||||
def ioctl(code: int, buf=None, size: int = 0) -> bool:
|
def ioctl(code: int, buf=None, size: int = 0) -> bool:
|
||||||
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
|
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
k32.FlushFileBuffers(handle)
|
k32.FlushFileBuffers(handle)
|
||||||
for _ in range(10): # Explorer/indexer may hold it briefly
|
for _ in range(10): # Explorer/indexer may hold it briefly
|
||||||
if ioctl(FSCTL_LOCK_VOLUME):
|
if ioctl(FSCTL_LOCK_VOLUME):
|
||||||
break
|
break
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
if not ioctl(FSCTL_DISMOUNT_VOLUME):
|
if not ioctl(FSCTL_DISMOUNT_VOLUME):
|
||||||
return False
|
return False
|
||||||
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
|
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
|
||||||
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
|
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
|
||||||
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
|
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
|
||||||
return True
|
return True
|
||||||
finally:
|
finally:
|
||||||
k32.CloseHandle(handle)
|
k32.CloseHandle(handle)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- macOS
|
# ------------------------------------------------------------------- macOS
|
||||||
|
|
||||||
def _format_macos(disk: Disk, log: list[str]) -> Outcome:
|
def _format_macos(disk: Disk, log: list[str]) -> Outcome:
|
||||||
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
|
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
|
||||||
proc = subprocess.run(["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)
|
capture_output=True, text=True, timeout=600)
|
||||||
log += [l for l in proc.stdout.splitlines() if l.strip()]
|
log += [l for l in proc.stdout.splitlines() if l.strip()]
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
|
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
|
||||||
+ " (is the card's write-protect switch on?)", log=log)
|
+ " (is the card's write-protect switch on?)", log=log)
|
||||||
part = f"{disk.id}s1"
|
part = f"{disk.id}s1"
|
||||||
for _ in range(20): # wait for the volume to mount
|
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>")
|
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
|
||||||
if info.get("MountPoint"):
|
if info.get("MountPoint"):
|
||||||
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
|
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return Outcome(False, "Formatted, but the card did not mount.", log=log)
|
return Outcome(False, "Formatted, but the card did not mount.", log=log)
|
||||||
|
|
||||||
|
|
||||||
def _eject_macos(disk_id: str) -> bool:
|
def _eject_macos(disk_id: str) -> bool:
|
||||||
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
|
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ common
|
# ------------------------------------------------------------------ common
|
||||||
|
|
||||||
def write_env(mount: str, env: EnvConfig) -> Outcome:
|
def write_env(mount: str, env: EnvConfig) -> Outcome:
|
||||||
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back."""
|
"""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"]
|
problems = [i.text for i in env.problems() if i.level == "error"]
|
||||||
if problems:
|
if problems:
|
||||||
return Outcome(False, "Settings are not valid: " + " ".join(problems))
|
return Outcome(False, "Settings are not valid: " + " ".join(problems))
|
||||||
root = Path(mount)
|
root = Path(mount)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return Outcome(False, f"Card not found at {mount}.")
|
return Outcome(False, f"Card not found at {mount}.")
|
||||||
old = find_case_insensitive(root, ENV_NAME)
|
old = find_case_insensitive(root, ENV_NAME)
|
||||||
if old is not None and old.name != ENV_NAME:
|
if old is not None and old.name != ENV_NAME:
|
||||||
old.unlink() # replace env.txt / Env.txt variants
|
old.unlink() # replace env.txt / Env.txt variants
|
||||||
target = root / ENV_NAME
|
target = root / ENV_NAME
|
||||||
data = env.render()
|
data = env.render()
|
||||||
with open(target, "wb") as fh:
|
with open(target, "wb") as fh:
|
||||||
fh.write(data)
|
fh.write(data)
|
||||||
fh.flush()
|
fh.flush()
|
||||||
os.fsync(fh.fileno())
|
os.fsync(fh.fileno())
|
||||||
if target.read_bytes() != data:
|
if target.read_bytes() != data:
|
||||||
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
|
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
|
||||||
check = parse_env(target.read_bytes())
|
check = parse_env(target.read_bytes())
|
||||||
if not check.ok:
|
if not check.ok:
|
||||||
return Outcome(False, "ENV.TXT was written but does not validate: "
|
return Outcome(False, "ENV.TXT was written but does not validate: "
|
||||||
+ " ".join(i.text for i in check.issues if i.level == "error"))
|
+ " ".join(i.text for i in check.issues if i.level == "error"))
|
||||||
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount)
|
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:
|
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"]
|
problems = [i.text for i in env.problems() if i.level == "error"]
|
||||||
if problems:
|
if problems:
|
||||||
return Outcome(False, "Settings are not valid: " + " ".join(problems))
|
return Outcome(False, "Settings are not valid: " + " ".join(problems))
|
||||||
log: list[str] = []
|
log: list[str] = []
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
fmt = _format_windows(disk, log)
|
fmt = _format_windows(disk, log)
|
||||||
elif sys.platform == "darwin":
|
elif sys.platform == "darwin":
|
||||||
fmt = _format_macos(disk, log)
|
fmt = _format_macos(disk, log)
|
||||||
else:
|
else:
|
||||||
return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
|
return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
|
||||||
if not fmt.ok:
|
if not fmt.ok:
|
||||||
return fmt
|
return fmt
|
||||||
wr = write_env(fmt.mount, env)
|
wr = write_env(fmt.mount, env)
|
||||||
log.append(wr.message)
|
log.append(wr.message)
|
||||||
if not wr.ok:
|
if not wr.ok:
|
||||||
return Outcome(False, wr.message, fmt.mount, log)
|
return Outcome(False, wr.message, fmt.mount, log)
|
||||||
if eject_card:
|
if eject_card:
|
||||||
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
|
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.")
|
"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)
|
return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
|
||||||
|
|
||||||
|
|
||||||
def eject(mount: str, disk: Disk | None = None) -> bool:
|
def eject(mount: str, disk: Disk | None = None) -> bool:
|
||||||
"""True if the card was dismounted and can be pulled out safely."""
|
"""True if the card was dismounted and can be pulled out safely."""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return _eject_windows(mount)
|
return _eject_windows(mount)
|
||||||
if sys.platform == "darwin" and disk is not None:
|
if sys.platform == "darwin" and disk is not None:
|
||||||
return _eject_macos(disk.id)
|
return _eject_macos(disk.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_card(mount: str, filesystem: str = "") -> CardReport:
|
def check_card(mount: str, filesystem: str = "") -> CardReport:
|
||||||
"""What the box would find on this card (values masked in the UI)."""
|
"""What the box would find on this card (values masked in the UI)."""
|
||||||
root = Path(mount)
|
root = Path(mount)
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
fs = filesystem.upper()
|
# Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
|
||||||
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"):
|
fs = filesystem.upper().replace("_", "").replace(" ", "")
|
||||||
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
|
if fs and "FAT32" not in fs and fs not in ("FAT", "MSDOS"):
|
||||||
env_file = find_case_insensitive(root, ENV_NAME)
|
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
|
||||||
env = parse_env(env_file.read_bytes()) if env_file else None
|
env_file = find_case_insensitive(root, ENV_NAME)
|
||||||
if env_file is None:
|
env = parse_env(env_file.read_bytes()) if env_file else None
|
||||||
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
|
if env_file is None:
|
||||||
elif len(env_file.name.split(".")[0]) > 8:
|
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
|
||||||
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
|
elif len(env_file.name.split(".")[0]) > 8:
|
||||||
buf = find_case_insensitive(root, BUFFER_NAME)
|
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
|
||||||
lines = None
|
buf = find_case_insensitive(root, BUFFER_NAME)
|
||||||
if buf is not None:
|
lines = None
|
||||||
with open(buf, "rb") as fh:
|
if buf is not None:
|
||||||
lines = sum(1 for ln in fh if ln.strip())
|
with open(buf, "rb") as fh:
|
||||||
return CardReport(mount, filesystem, env, lines, problems)
|
lines = sum(1 for ln in fh if ln.strip())
|
||||||
|
return CardReport(mount, filesystem, env, lines, problems)
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kota-technician-tool"
|
name = "kota-technician-tool"
|
||||||
version = "0.7.0"
|
version = "0.9.0"
|
||||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||||
requires-python = ">=3.11,<3.14"
|
requires-python = ">=3.11,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+4
-2
@@ -2,8 +2,10 @@
|
|||||||
# Double-click in Finder, or run: bash run_macos.command
|
# Double-click in Finder, or run: bash run_macos.command
|
||||||
cd "$(dirname "$0")" || exit 1
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
# If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
|
||||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.7"
|
# 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"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
if ! command -v uv >/dev/null 2>&1; then
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
setlocal
|
setlocal
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
rem One shared Python environment for every version (update_windows.bat keeps it current).
|
||||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.7"
|
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
|
||||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||||
|
|
||||||
where uv >nul 2>nul
|
where uv >nul 2>nul
|
||||||
|
|||||||
@@ -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 top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
|
||||||
assert "reboot.loop" in {c.rule_id for c in top.consequences}
|
assert "reboot.loop" in {c.rule_id for c in top.consequences}
|
||||||
assert r.verdict.startswith("Problem: Power supply")
|
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,3 +12,15 @@ def test_other_ports_not_sensorbox():
|
|||||||
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
|
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
|
||||||
assert not bt.is_sensorbox and bt.mac is None
|
assert not bt.is_sensorbox and bt.mac is None
|
||||||
assert bt.label == "COM3 - Standard Serial over Bluetooth link"
|
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"]
|
||||||
@@ -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()
|
assert work.parent == tmp_path / "tmp" and work.is_dir()
|
||||||
(work / "result.json").write_text('{"ok": true}', encoding="utf-8")
|
(work / "result.json").write_text('{"ok": true}', encoding="utf-8")
|
||||||
assert (work / "result.json").read_text(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)
|
||||||
Executable
+74
@@ -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
|
||||||
@@ -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
|
||||||
@@ -250,7 +250,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kota-technician-tool"
|
name = "kota-technician-tool"
|
||||||
version = "0.7.0"
|
version = "0.9.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "esptool" },
|
{ name = "esptool" },
|
||||||
|
|||||||
Reference in new issue
Block a user