Compare commits
@@ -95,11 +95,19 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
|
||||
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
|
||||
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
|
||||
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
|
||||
sauber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt), aber
|
||||
darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang
|
||||
am EVK gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout — das `RECV_TIMEOUT` von 5 s des
|
||||
HTTP-Clients greift dort nicht — und bricht danach ohne Wiederaufnahme ab. Die Lampe bleibt in dieser Zeit
|
||||
über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab.
|
||||
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
|
||||
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
|
||||
|
||||
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
|
||||
`RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
|
||||
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
|
||||
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
|
||||
diesen Fall darf man sich also verlassen.
|
||||
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
|
||||
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
|
||||
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
|
||||
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
|
||||
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
|
||||
|
||||
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
|
||||
|
||||
|
||||
@@ -225,6 +225,11 @@ items:
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
docs: |-
|
||||
`wifi.source` ist `"stored"`, solange ein per PUT onboardetes WLAN gespeichert ist,
|
||||
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
|
||||
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
|
||||
eine verbundene SSID zeigen.
|
||||
- info:
|
||||
name: Set WiFi
|
||||
type: http
|
||||
|
||||
@@ -59,11 +59,11 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
|
||||
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
|
||||
|
||||
## Später (Lampen)
|
||||
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
|
||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
|
||||
- [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
|
||||
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
|
||||
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
|
||||
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
|
||||
- [ ] Farbtemperaturbereiche aller Varianten
|
||||
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: 2500 K deutlich warm, 5000 K deutlich kühler, Dimmen auf 10 % flackerfrei, weiches Ausblenden
|
||||
- [ ] Farbtemperaturbereiche aller Varianten — *g95_cw (2500–5000 K) und gu10_rgb (2700–6500 K) am 2026-09-13 an echten Lampen bestätigt; die übrigen sechs Varianten stehen aus*
|
||||
|
||||
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
|
||||
|
||||
@@ -73,3 +73,26 @@ unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läuf
|
||||
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
|
||||
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
|
||||
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
|
||||
|
||||
## Recovery gegen die installierte Pi-Station (2026-09-13)
|
||||
|
||||
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
|
||||
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
|
||||
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
|
||||
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
|
||||
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
|
||||
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
|
||||
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
|
||||
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
|
||||
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
|
||||
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
|
||||
|
||||
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
|
||||
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
|
||||
sieht pro Boot einen vollen Transfer.
|
||||
|
||||
## Migration echter Lampen (Plan 4b, 2026-09-13)
|
||||
|
||||
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
|
||||
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
|
||||
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#define SM_ADDR_CURRENT 0xC0u
|
||||
#define SM_ADDR_WY 0xC5u
|
||||
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
|
||||
#define SM_CHANNEL_STBY 0x20u /* standby bit 5: powers the outputs down */
|
||||
|
||||
extern void delay_us(unsigned int us); /* provided by libwlan.a */
|
||||
|
||||
@@ -76,7 +77,15 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
|
||||
s_have_last = true;
|
||||
|
||||
if (!white) {
|
||||
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, 0x00, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||
/* All channels dark: put the driver into standby instead of just writing zero duty.
|
||||
Without bit 5 the chip stays active and leaks enough current to keep the lamp
|
||||
faintly glowing - measured on a migrated GU10 RGB, 2026-09-13. The old QConnex
|
||||
firmware had this logic but left it commented out (qcl_lamp.c), which is why the
|
||||
behaviour was inherited. Lamps driven straight from PWM pins (G95, the CW variants)
|
||||
are unaffected. Turning back on writes 0x00 or SM_CHANNEL_WY here and clears it. */
|
||||
bool dark = !ch[NL_CH_R] && !ch[NL_CH_G] && !ch[NL_CH_B];
|
||||
uint8_t chan = dark ? SM_CHANNEL_STBY : 0x00u;
|
||||
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
|
||||
sm_send(d, 6);
|
||||
} else {
|
||||
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY};
|
||||
|
||||
@@ -262,6 +262,13 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
|
||||
cJSON_AddStringToObject(o, "name", s.name);
|
||||
w = cJSON_AddObjectToObject(o, "wifi");
|
||||
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
|
||||
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
|
||||
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
|
||||
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
|
||||
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
|
||||
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
|
||||
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
|
||||
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
|
||||
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
|
||||
reply_json(resp, 200, o);
|
||||
}
|
||||
|
||||
+13
-4
@@ -94,10 +94,19 @@ static void ota_task(void *arg)
|
||||
(void)arg;
|
||||
log_url(s_url);
|
||||
rc = t_http_fwup(s_url);
|
||||
/* Only reached on failure - the SDK resets the chip itself after a good image. */
|
||||
if (s_error == NULL && !s_skipped)
|
||||
s_error = "download";
|
||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||
/* Only reached on failure or a deliberate skip - the SDK resets the chip itself after a good
|
||||
* image. rc is not a reliable success/failure signal here: the SDK aborts the transfer as
|
||||
* soon as the trailer hook returns false, which happens both for a real rejection and for the
|
||||
* deliberate "recovery image already up to date" skip. s_error/s_skipped are what actually
|
||||
* distinguish the two, so branch on those instead of folding everything into one printf that
|
||||
* would risk passing a NULL s_error to %s. */
|
||||
if (s_skipped) {
|
||||
printf("ota: skipped (rc=%d, image already up to date)\r\n", rc);
|
||||
} else {
|
||||
if (s_error == NULL)
|
||||
s_error = "download";
|
||||
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
|
||||
}
|
||||
{
|
||||
nl_settings_t *s;
|
||||
nl_app_lock();
|
||||
|
||||
@@ -233,6 +233,7 @@ static void test_settings_auth(void)
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
|
||||
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||
TEST_ASSERT_EQUAL_STRING("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
|
||||
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
|
||||
fake_open = true;
|
||||
@@ -240,6 +241,19 @@ static void test_settings_auth(void)
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
}
|
||||
|
||||
/* F1: nie onboardet -> gespeicherte ssid ist leer, die Lampe laeuft auf den
|
||||
* einkompilierten Zugangsdaten (siehe app_main.c). "source" muss das additiv
|
||||
* sichtbar machen, ohne "ssid" umzudeuten - sonst widerspricht die Antwort
|
||||
* scheinbar system/info, das die tatsaechlich verbundene SSID zeigt. */
|
||||
static void test_settings_wifi_source_reflects_compiled_fallback(void)
|
||||
{
|
||||
fake_settings.wifi_ssid[0] = '\0';
|
||||
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
|
||||
TEST_ASSERT_EQUAL_STRING("compiled", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
|
||||
}
|
||||
|
||||
static void test_settings_wifi(void)
|
||||
{
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
|
||||
@@ -356,6 +370,7 @@ int main(void)
|
||||
RUN_TEST(test_info_is_open);
|
||||
RUN_TEST(test_info_with_sta_ip_and_error);
|
||||
RUN_TEST(test_settings_auth);
|
||||
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
|
||||
RUN_TEST(test_settings_wifi);
|
||||
RUN_TEST(test_settings_name_token_auth);
|
||||
RUN_TEST(test_commands);
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
|
||||
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
|
||||
firmware hashes while writing the image to flash.
|
||||
|
||||
Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the
|
||||
x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware
|
||||
build, and that container has not been upgraded past 3.8. This is independent of the
|
||||
"Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that
|
||||
runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this
|
||||
file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`)
|
||||
in annotations stay text and are never evaluated - without it, that syntax raises
|
||||
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See
|
||||
test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
@@ -4,4 +4,4 @@ version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
dev = ["pytest>=8", "vermin>=1.6"]
|
||||
|
||||
@@ -208,3 +208,71 @@ def test_verify_with_build_arguments_is_an_error(tmp_path):
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "cannot be combined" in r.stderr
|
||||
|
||||
|
||||
# --- Python 3.8 compatibility -------------------------------------------------------------
|
||||
#
|
||||
# CMake calls this script via plain `python3` inside the build container on every firmware
|
||||
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
|
||||
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
|
||||
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
|
||||
# git history around the "unsupported operand type(s) for |" break for what happens when it
|
||||
# regresses.
|
||||
#
|
||||
# Two checks, because neither alone covers the failure mode we actually hit:
|
||||
# 1. `from __future__ import annotations` must be the first statement after the module
|
||||
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
|
||||
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
|
||||
# them get added later.
|
||||
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
|
||||
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
|
||||
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
|
||||
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
|
||||
# accounting for `from __future__ import annotations` deferring evaluation - which would
|
||||
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
|
||||
# what actually guards against that pattern.
|
||||
|
||||
|
||||
def test_future_annotations_import_is_present():
|
||||
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
|
||||
import ast
|
||||
|
||||
tree = ast.parse(SCRIPT.read_text())
|
||||
body = tree.body
|
||||
# Skip a leading module docstring, if any.
|
||||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
||||
and isinstance(body[0].value.value, str):
|
||||
body = body[1:]
|
||||
assert body, "nl_mkimage.py is unexpectedly empty"
|
||||
first = body[0]
|
||||
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
|
||||
and any(alias.name == "annotations" for alias in first.names), (
|
||||
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
|
||||
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
|
||||
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
|
||||
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
|
||||
)
|
||||
|
||||
|
||||
def test_stays_python38_compatible():
|
||||
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
|
||||
|
||||
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
|
||||
version from syntax/stdlib usage instead of actually executing the file.
|
||||
"""
|
||||
vermin = __import__("vermin")
|
||||
|
||||
config = vermin.Config()
|
||||
config.set_quiet(True)
|
||||
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
|
||||
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
|
||||
# they need runtime support either. This is vermin's default, spelled out here so it's
|
||||
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
|
||||
config.set_eval_annotations(False)
|
||||
|
||||
source = SCRIPT.read_text()
|
||||
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
|
||||
assert py3_min is not None and py3_min <= (3, 8), (
|
||||
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
|
||||
"Python 3.8.10 in the Docker build container (see module docstring)"
|
||||
)
|
||||
|
||||
Generated
+14
-1
@@ -28,12 +28,16 @@ source = { virtual = "." }
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "vermin" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "vermin", specifier = ">=1.6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
@@ -77,3 +81,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vermin"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/7b/2c1b403f2a844e1acb36694fc336e323df742f7f752edb4188311ad43f9e/vermin-1.8.0.tar.gz", hash = "sha256:3621955ac2a2950175c5b4a9b2fc3bd24bd416da0388893c9eb6971264e4ca1f", size = 99913, upload-time = "2025-11-20T17:29:31.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/fd/6d2167767488e162efd01300a679c7f7d913dbb65baa0b15760ebb369854/vermin-1.8.0-py3-none-any.whl", hash = "sha256:6f2b98ad697a16ed3442e81a0f0baabfc38c185b78aaab1b1fecd87bdd6640b8", size = 98030, upload-time = "2025-11-20T17:29:29.931Z" },
|
||||
]
|
||||
|
||||
@@ -11,6 +11,12 @@ Lötkolben käme.
|
||||
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
|
||||
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
|
||||
|
||||
## Voraussetzung
|
||||
|
||||
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
|
||||
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
|
||||
würde dort nur mit einer Fehlermeldung enden.
|
||||
|
||||
## Bedienung
|
||||
|
||||
tools/station/setup_pi.sh <pi-host> einmalig einrichten
|
||||
|
||||
+41
-5
@@ -9,6 +9,10 @@ shift || true
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT=$(cd "$HERE/../.." && pwd)
|
||||
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
||||
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
|
||||
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
||||
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||
|
||||
declare -a FILES=()
|
||||
if [ $# -gt 0 ]; then
|
||||
@@ -23,7 +27,23 @@ if [ $# -gt 0 ]; then
|
||||
done
|
||||
else
|
||||
FULL_SYNC=1
|
||||
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
||||
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
||||
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
||||
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
||||
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
||||
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
||||
# kein Zufall.
|
||||
declare -A SEEN_AT=()
|
||||
while IFS= read -r f; do
|
||||
base=$(basename "$f")
|
||||
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
||||
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
||||
exit 1
|
||||
fi
|
||||
SEEN_AT[$base]=$f
|
||||
FILES+=("$f")
|
||||
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
||||
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
@@ -54,11 +74,12 @@ echo "== uebertragen =="
|
||||
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
|
||||
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
|
||||
# ausgelieferte Verzeichnis.
|
||||
rsync -a -c --delete "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
||||
rsync -a "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
|
||||
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
||||
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
||||
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
||||
ssh "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
|
||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
|
||||
set -e
|
||||
FULL_SYNC=$1
|
||||
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||
@@ -68,7 +89,22 @@ UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||
sudo install -d -m 0755 "$ROOT_DIR/images"
|
||||
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
||||
echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt"
|
||||
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
||||
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
||||
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
||||
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
||||
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
||||
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
||||
REMOVED=""
|
||||
if [ -n "$OLD_LIST" ]; then
|
||||
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
||||
fi
|
||||
if [ -n "$REMOVED" ]; then
|
||||
echo " voller Abgleich - folgende Images werden entfernt:"
|
||||
echo "$REMOVED" | sed 's/^/ /'
|
||||
else
|
||||
echo " voller Abgleich - keine Images werden entfernt"
|
||||
fi
|
||||
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||
else
|
||||
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
||||
|
||||
@@ -66,6 +66,13 @@ on)
|
||||
sleep 1
|
||||
if systemctl is-active --quiet ninlight-station; then
|
||||
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
|
||||
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
|
||||
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
|
||||
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
|
||||
# ausschalten) stand hier schon.
|
||||
if [ "$PSK" = "ninlight-recovery" ]; then
|
||||
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
|
||||
fi
|
||||
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
||||
else
|
||||
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
|
||||
@@ -91,7 +98,13 @@ off)
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || echo inaktiv)"
|
||||
# "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und
|
||||
# liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist -
|
||||
# ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide
|
||||
# landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
|
||||
# erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand - deshalb
|
||||
# am Ende explizit exit 0.
|
||||
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
|
||||
printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)"
|
||||
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
|
||||
echo "Images :"
|
||||
@@ -104,7 +117,23 @@ print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" el
|
||||
PY
|
||||
)"
|
||||
done
|
||||
[ -r /run/ninlight-station/status.json ] && { echo "Zuletzt ausgeliefert:"; sed 's/^/ /' /run/ninlight-station/status.json; }
|
||||
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
|
||||
# ausgeliefert wurde - ohne das "|| true" waere der Exit-Code des Skripts hier der des
|
||||
# fehlgeschlagenen Tests (1), weil das die letzte Anweisung im Zweig ist.
|
||||
#
|
||||
# "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
|
||||
# sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
|
||||
# "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
|
||||
# server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
|
||||
# "noch nie ausgeliefert".
|
||||
STATUS_FILE=/run/ninlight-station/status.json
|
||||
if [ -r "$STATUS_FILE" ]; then
|
||||
echo "Zuletzt ausgeliefert:"
|
||||
sed 's/^/ /' "$STATUS_FILE"
|
||||
elif [ -e "$STATUS_FILE" ]; then
|
||||
echo "Fehler: $STATUS_FILE existiert, ist aber nicht lesbar (Rechte pruefen)" >&2
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Aufruf: ninlight-station on|off|status" >&2
|
||||
|
||||
+75
-26
@@ -10,6 +10,7 @@ Standard library only - the Pi gets no extra packages.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -60,16 +61,35 @@ class Station:
|
||||
self.since = now_iso()
|
||||
self.served = 0
|
||||
self.recent: list[dict] = []
|
||||
# Cumulative bytes already delivered to each client while --stall-after is active,
|
||||
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
|
||||
# resumes with Range right where it left off, so a per-request counter would hand
|
||||
# every reconnect a brand-new --stall-after allowance and the stall would never
|
||||
# actually stick (see reserve_stall_bytes below).
|
||||
self.stall_sent: dict[str, int] = {}
|
||||
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
||||
# can be downloading at once. Every mutation of served/recent and the snapshot
|
||||
# that gets serialized to disk must happen under this same lock, or concurrent
|
||||
# requests lose increments and stomp on each other's status.json writes.
|
||||
# can be downloading at once. Every mutation of served/recent/stall_sent and the
|
||||
# snapshot that gets serialized to disk must happen under this same lock, or
|
||||
# concurrent requests lose increments and stomp on each other's status.json writes.
|
||||
self.lock = threading.RLock()
|
||||
self.write_state()
|
||||
|
||||
def image_path(self, target: str) -> Path:
|
||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
||||
|
||||
def reserve_stall_bytes(self, ip: str, n: int) -> int:
|
||||
"""How many of the next `n` bytes this client may still receive under --stall-after.
|
||||
|
||||
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
|
||||
that the lamp abandons and resumes via Range does not get a fresh budget just
|
||||
because it is a new TCP connection. Only called when stall_after is set.
|
||||
"""
|
||||
with self.lock:
|
||||
sent_so_far = self.stall_sent.get(ip, 0)
|
||||
allowed = max(0, min(n, self.stall_after - sent_so_far))
|
||||
self.stall_sent[ip] = sent_so_far + allowed
|
||||
return allowed
|
||||
|
||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||
with self.lock:
|
||||
if status == "ok":
|
||||
@@ -85,9 +105,24 @@ class Station:
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
os.replace(tmp, self.state_path)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
# The service runs as root and mkstemp() creates the file 0600, so
|
||||
# without this an operator reading status.json as a normal user (see
|
||||
# tools/station/ninlight-station) gets "permission denied" - and since
|
||||
# os.replace() preserves the mode it arrives with, that wrong mode
|
||||
# would otherwise survive onto the live file on every single write,
|
||||
# not just the first. os.chmod() ignores the process umask (unlike the
|
||||
# mode argument to mkstemp/open), so a restrictive umask on the Pi
|
||||
# can't quietly bring the 0600 back. Set it before the rename so
|
||||
# there's never a window where the path holds a 0600 file.
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, self.state_path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
except OSError as e:
|
||||
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||
|
||||
@@ -103,12 +138,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||
pass
|
||||
|
||||
def log_error(self, fmt: str, *args) -> None:
|
||||
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
|
||||
# silence above in favour of our own log() calls - but that also silences it for
|
||||
# requests that never reach do_GET/do_POST (a malformed request line, a client
|
||||
# timeout mid-request). The station is the only diagnostic tool available during a
|
||||
# rescue, so those cases must still produce a line instead of nothing.
|
||||
try:
|
||||
client = self.client_address[0]
|
||||
except (AttributeError, IndexError):
|
||||
client = "?"
|
||||
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
|
||||
|
||||
def log(self, status: int, nbytes: int, started: float) -> None:
|
||||
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
started = time.monotonic()
|
||||
self.reject(405, "method not allowed")
|
||||
self.log(405, 0, started)
|
||||
|
||||
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||
|
||||
@@ -159,31 +208,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
sent = 0
|
||||
status = "ok"
|
||||
remaining = size - start
|
||||
client_ip = self.client_address[0]
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
f.seek(start)
|
||||
while sent < remaining:
|
||||
if self.station.stall_after is not None and sent >= self.station.stall_after:
|
||||
# test aid: hold the connection open and send nothing more, so the
|
||||
# firmware's stall handling can be exercised on real hardware. We
|
||||
# still notice if the peer gives up and closes its side, so a test
|
||||
# (or an impatient client) that hangs up is recorded as "aborted"
|
||||
# instead of leaking the thread forever.
|
||||
while True:
|
||||
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||
except OSError:
|
||||
peek = b""
|
||||
if not peek:
|
||||
raise ConnectionResetError("peer closed during stall")
|
||||
read_size = CHUNK
|
||||
read_size = min(CHUNK, remaining - sent)
|
||||
if self.station.stall_after is not None:
|
||||
# Don't overshoot the stall boundary within a single read: without
|
||||
# this a whole CHUNK could be sent before the check above ever
|
||||
# notices we've passed --stall-after.
|
||||
read_size = min(read_size, max(1, self.station.stall_after - sent))
|
||||
# Reserve against this client's cumulative budget, not just this
|
||||
# request's byte count - see Station.reserve_stall_bytes.
|
||||
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
|
||||
if read_size == 0:
|
||||
# test aid: hold the connection open and send nothing more, so
|
||||
# the firmware's stall handling can be exercised on real
|
||||
# hardware. We still notice if the peer gives up and closes its
|
||||
# side, so a test (or an impatient client) that hangs up is
|
||||
# recorded as "aborted" instead of leaking the thread forever.
|
||||
while True:
|
||||
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||
except OSError:
|
||||
peek = b""
|
||||
if not peek:
|
||||
raise ConnectionResetError("peer closed during stall")
|
||||
chunk = f.read(read_size)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
@@ -6,17 +6,22 @@
|
||||
set -euo pipefail
|
||||
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
|
||||
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT in deploy.sh), damit ein Test gegen Stubs statt
|
||||
# einen echten Pi laufen kann.
|
||||
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
||||
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
|
||||
|
||||
echo "== Voraussetzungen auf $HOST pruefen =="
|
||||
ssh "$HOST" 'set -e
|
||||
"$SSH_BIN" "$HOST" 'set -e
|
||||
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
|
||||
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
|
||||
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
|
||||
echo " ok"'
|
||||
|
||||
echo "== Konfiguration =="
|
||||
scp -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
|
||||
ssh "$HOST" 'set -e
|
||||
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
|
||||
"$SSH_BIN" "$HOST" 'set -e
|
||||
if [ -f /etc/ninlight-station.conf ]; then
|
||||
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert"
|
||||
else
|
||||
@@ -26,8 +31,8 @@ ssh "$HOST" 'set -e
|
||||
rm -f /tmp/ninlight-station.conf.example'
|
||||
|
||||
echo "== Server, CLI und Unit installieren =="
|
||||
scp -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
ssh "$HOST" 'set -e
|
||||
"$SCP_BIN" -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
"$SSH_BIN" "$HOST" 'set -e
|
||||
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images
|
||||
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
|
||||
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
|
||||
@@ -49,7 +54,7 @@ echo "== NetworkManager-Profil =="
|
||||
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
|
||||
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
|
||||
# Prozessliste.
|
||||
ssh "$HOST" bash -s <<'REMOTE'
|
||||
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
eval "$(sudo cat /etc/ninlight-station.conf)"
|
||||
CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection"
|
||||
|
||||
@@ -114,6 +114,7 @@ run_deploy() {
|
||||
# in der Sandbox muessen sie fuer "install" eigens angelegt werden.
|
||||
mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
|
||||
PATH="$STUBBIN:$PATH" \
|
||||
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
|
||||
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
|
||||
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
|
||||
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
|
||||
@@ -232,6 +233,7 @@ echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
set +e
|
||||
OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 \
|
||||
PATH="$STUBBIN:$PATH" \
|
||||
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
|
||||
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
|
||||
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
|
||||
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
|
||||
@@ -250,6 +252,110 @@ else
|
||||
pass "'Dienst laeuft nicht' wird nicht faelschlich gemeldet"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 6: deploy.sh/setup_pi.sh nutzen ohne Override /usr/bin/ssh (bzw. scp) ==="
|
||||
# Die anderen Szenarien laufen mit NINLIGHT_SSH_BIN auf einen Stub umgebogen - das prueft die
|
||||
# Logik, aber nicht mehr, WELCHEN ssh das Skript ohne Override tatsaechlich waehlt. Der Nutzer
|
||||
# verlangt ausdruecklich /usr/bin/ssh und /usr/bin/scp statt einer Variante im PATH; das hier
|
||||
# ist die Absicherung dagegen, dass der Default beim naechsten Umbau stillschweigend zurueck
|
||||
# auf ein PATH-"ssh" rutscht.
|
||||
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/deploy.sh"; then
|
||||
pass "deploy.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
|
||||
else
|
||||
fail "deploy.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
|
||||
fi
|
||||
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/setup_pi.sh"; then
|
||||
pass "setup_pi.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
|
||||
else
|
||||
fail "setup_pi.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
|
||||
fi
|
||||
if grep -q 'SCP_BIN=\${NINLIGHT_SCP_BIN:-/usr/bin/scp}' "$STATION_DIR/setup_pi.sh"; then
|
||||
pass "setup_pi.sh: Default fuer SCP_BIN ist /usr/bin/scp"
|
||||
else
|
||||
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 7: Voll-Deploy mit zwei Build-Verzeichnissen fuer denselben Dateinamen -> Abbruch (B1) ==="
|
||||
SANDBOX="$TMPROOT/s7"; STUBBIN="$TMPROOT/bin7"; LOG="$TMPROOT/s7.log"; SANDBOX_STATE="$TMPROOT/s7-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/evk" "$SANDBOX/build/evk-onb"
|
||||
echo "evk-mit-wlan" >"$SANDBOX/build/evk/ninlight_evk_ota.img"
|
||||
echo "evk-ohne-wlan" >"$SANDBOX/build/evk-onb/ninlight_evk_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0, obwohl zwei Fundorte denselben Dateinamen liefern"; fi
|
||||
if echo "$OUT" | grep -q "ninlight_evk_ota.img mehrfach gefunden"; then
|
||||
pass "Fehlermeldung nennt den doppelten Dateinamen"
|
||||
else
|
||||
fail "Fehlermeldung zum doppelten Dateinamen fehlt: $OUT"
|
||||
fi
|
||||
if echo "$OUT" | grep -q "build/evk/ninlight_evk_ota.img" && echo "$OUT" | grep -q "build/evk-onb/ninlight_evk_ota.img"; then
|
||||
pass "Fehlermeldung nennt beide Fundorte"
|
||||
else
|
||||
fail "Fehlermeldung nennt nicht beide Fundorte: $OUT"
|
||||
fi
|
||||
if ! grep -q "STUB" "$LOG" 2>/dev/null; then
|
||||
pass "kein Transfer-Kommando aufgerufen - nichts wurde stillschweigend uebertragen"
|
||||
else
|
||||
fail "ein Transfer-Kommando wurde trotz doppeltem Dateinamen aufgerufen: $(cat "$LOG")"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 8: Voll-Deploy zeigt vor dem Loeschen, welche Images verschwinden (B2) ==="
|
||||
SANDBOX="$TMPROOT/s8"; STUBBIN="$TMPROOT/bin8"; LOG="$TMPROOT/s8.log"; SANDBOX_STATE="$TMPROOT/s8-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/foo"
|
||||
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
|
||||
echo "veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img"
|
||||
echo "auch-veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_e14_cw_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||
if echo "$OUT" | grep -q "folgende Images werden entfernt"; then
|
||||
pass "Ausgabe kuendigt die zu entfernenden Images an, bevor geloescht wird"
|
||||
else
|
||||
fail "Ausgabe nennt die zu entfernenden Images nicht: $OUT"
|
||||
fi
|
||||
if echo "$OUT" | grep -q "ninlight_veraltet_ota.img" && echo "$OUT" | grep -q "ninlight_e14_cw_ota.img"; then
|
||||
pass "beide zu entfernenden Dateien werden namentlich genannt"
|
||||
else
|
||||
fail "nicht alle zu entfernenden Dateien werden genannt: $OUT"
|
||||
fi
|
||||
if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" ]; then
|
||||
pass "veraltetes Image wurde nach der Ankuendigung tatsaechlich entfernt"
|
||||
else
|
||||
fail "veraltetes Image wurde trotz Ankuendigung nicht entfernt"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Szenario 9: Voll-Deploy ohne verwaiste Images meldet das statt zu schweigen (B2) ==="
|
||||
SANDBOX="$TMPROOT/s9"; STUBBIN="$TMPROOT/bin9"; LOG="$TMPROOT/s9.log"; SANDBOX_STATE="$TMPROOT/s9-state"
|
||||
setup_sandbox "$SANDBOX"
|
||||
setup_stubbin "$STUBBIN" "$LOG"
|
||||
mkdir -p "$SANDBOX/build/foo"
|
||||
echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
|
||||
: >"$LOG"
|
||||
set +e
|
||||
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
|
||||
if echo "$OUT" | grep -q "keine Images werden entfernt"; then
|
||||
pass "Ausgabe meldet explizit, dass nichts entfernt wird"
|
||||
else
|
||||
fail "Ausgabe meldet nicht, dass nichts entfernt wird: $OUT"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "Alle Tests bestanden."
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -90,6 +93,44 @@ class Station:
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=5)
|
||||
|
||||
def wait_for_log_line(self, contains, timeout=3.0):
|
||||
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
|
||||
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
|
||||
check that a request actually produced a log line (F3: every request must,
|
||||
including rejected ones and errors the base handler catches before routing).
|
||||
|
||||
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
|
||||
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
|
||||
read() can slurp everything currently sitting in the pipe (banner *and* the log
|
||||
line we're waiting for) into its own buffer on the very first call, while only
|
||||
handing back the first line. select() then reports the fd as not-readable on the
|
||||
next iteration - the data isn't gone, it is just buffered somewhere select() can't
|
||||
see - and a fixed-size test timeout would flake on exactly the line it's checking
|
||||
for.
|
||||
"""
|
||||
fd = self.proc.stdout.fileno()
|
||||
buf = getattr(self, "_log_buf", "")
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
while "\n" in buf:
|
||||
line, buf = buf.split("\n", 1)
|
||||
self._log_buf = buf
|
||||
if contains in line:
|
||||
return line + "\n"
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
self._log_buf = buf
|
||||
return None
|
||||
r, _, _ = select.select([fd], [], [], remaining)
|
||||
if not r:
|
||||
self._log_buf = buf
|
||||
return None
|
||||
chunk = os.read(fd, 65536)
|
||||
if not chunk:
|
||||
self._log_buf = buf
|
||||
return None
|
||||
buf += chunk.decode("utf-8", "replace")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def station(tmp_path):
|
||||
@@ -137,6 +178,43 @@ def test_other_methods_are_405(station):
|
||||
assert c.getresponse().status == 405
|
||||
|
||||
|
||||
def test_rejected_method_produces_a_log_line(station):
|
||||
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
|
||||
the station is the only diagnostic tool during a rescue, and the spec requires a line
|
||||
per request, rejections included."""
|
||||
c = station.conn()
|
||||
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
||||
assert c.getresponse().status == 405
|
||||
line = station.wait_for_log_line(" 405 ")
|
||||
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
|
||||
assert "/ninlight_evk_ota.img" in line
|
||||
|
||||
|
||||
def test_log_error_is_not_silenced_by_log_message(capsys):
|
||||
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
|
||||
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
|
||||
server.py silences log_message() in favour of its own per-request log() calls - which
|
||||
used to silence log_error() right along with it, since it wasn't overridden separately.
|
||||
This exercises Handler.log_error() directly rather than over a raw socket: this
|
||||
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
|
||||
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
|
||||
genuine malformed-request-line test unreliable here."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class _Stub:
|
||||
client_address = ("203.0.113.5", 54321)
|
||||
|
||||
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
|
||||
out = capsys.readouterr().out
|
||||
assert "ERROR" in out
|
||||
assert "203.0.113.5" in out
|
||||
assert "code 400, message Bad request version" in out
|
||||
|
||||
|
||||
def test_range_request_returns_206_and_the_right_slice(station):
|
||||
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||
c = station.conn()
|
||||
@@ -173,6 +251,43 @@ def test_state_file_records_a_missing_image(station):
|
||||
assert state["recent"][-1]["target"] == "g95_cw"
|
||||
|
||||
|
||||
def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
||||
"""status.json is written by a root service but read by a normal-user CLI.
|
||||
|
||||
tempfile.mkstemp() creates its file 0600 and os.replace() preserves whatever mode
|
||||
the temp file had, so the server must explicitly widen the mode before the rename -
|
||||
and must do so with chmod() rather than relying on the mode passed to mkstemp/open,
|
||||
because that one is still narrowed by the process umask. A restrictive umask (as
|
||||
root services on the Pi may well run under) must not bring the 0600 back.
|
||||
"""
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
s = Station(tmp_path)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
try:
|
||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
||||
|
||||
def world_readable() -> bool:
|
||||
mode = stat.S_IMODE(s.state.stat().st_mode)
|
||||
return bool(mode & stat.S_IROTH)
|
||||
|
||||
# Written once already at startup (Station.__init__ -> write_state()).
|
||||
s.read_state(lambda _: world_readable())
|
||||
assert world_readable(), "status.json is not world-readable after the initial write"
|
||||
|
||||
# A second write (a real delivery) must not lose the mode either - e.g. if the
|
||||
# widening were only applied to the file's very first creation instead of to
|
||||
# every temp file, mode 0600 would silently come back on every later write.
|
||||
c = s.conn()
|
||||
c.request("GET", "/ninlight_evk_ota.img")
|
||||
c.getresponse().read()
|
||||
s.read_state(lambda st: st["served"] >= 1)
|
||||
assert world_readable(), "status.json lost its mode on a later overwrite"
|
||||
finally:
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_recent_is_capped_at_eight(station):
|
||||
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
||||
for _ in range(12):
|
||||
@@ -239,6 +354,62 @@ def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
|
||||
"""--stall-after must stop a client for good, not just for one request.
|
||||
|
||||
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
|
||||
on the connection, and opens a new one with a Range header picking up right where it
|
||||
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
|
||||
exercises for a single request. A per-request byte counter would hand that resumed
|
||||
request a brand-new --stall-after allowance, so the "stall" would only ever delay
|
||||
delivery instead of actually stopping it - which is what was observed on the EVK: the
|
||||
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
|
||||
away with the complete image. This test reproduces that reconnect and checks that the
|
||||
second, resumed request gets no further bytes at all.
|
||||
"""
|
||||
s = Station(tmp_path, "--stall-after", "1000")
|
||||
try:
|
||||
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||
|
||||
# First request: burns through the whole --stall-after budget, then the server
|
||||
# holds the connection open and sends nothing further.
|
||||
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||
got = b""
|
||||
sock.settimeout(2)
|
||||
try:
|
||||
while len(got) < 200000:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
got += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
assert len(got) < 20000 # far less than the full image, same bound as above
|
||||
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
|
||||
|
||||
# Second request, resuming with Range exactly like a real lamp would. This
|
||||
# client's cumulative budget is already spent, so it must get zero more bytes of
|
||||
# body - not a fresh --stall-after allowance just because it's a new connection.
|
||||
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
|
||||
sock2.settimeout(2)
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
header += sock2.recv(4096)
|
||||
assert b" 206 " in header.split(b"\r\n", 1)[0]
|
||||
body_after_header = header.split(b"\r\n\r\n", 1)[1]
|
||||
assert body_after_header == b"", "resumed request must not receive any body bytes"
|
||||
try:
|
||||
more = sock2.recv(1)
|
||||
except socket.timeout:
|
||||
more = None
|
||||
assert more in (None, b""), "server sent data after the client's stall budget was spent"
|
||||
sock2.close()
|
||||
finally:
|
||||
s.stop()
|
||||
|
||||
|
||||
def test_concurrent_downloads_are_all_recorded(station):
|
||||
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
||||
|
||||
|
||||
Generated
+79
@@ -0,0 +1,79 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ninlight-station"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user