diff --git a/environment.yml b/environment.yml index 08969ee..4961af5 100644 --- a/environment.yml +++ b/environment.yml @@ -7,4 +7,5 @@ dependencies: - python=3.12 - pyserial>=3.5 - pyside6>=6.7 + - esptool>=4.8,<5 - pytest>=8 diff --git a/firmware/firmware.toml b/firmware/firmware.toml new file mode 100644 index 0000000..fe67c1a --- /dev/null +++ b/firmware/firmware.toml @@ -0,0 +1,11 @@ +# Official firmware images the technician tool may flash. +# Boxes normally receive new firmware over OTA; this is the factory image a +# box updates itself from once it is online. Add a new image only when the +# firmware team releases one: copy the .bin here and add an entry with its +# SHA-256 (the tool refuses files that do not match). + +[[image]] +file = "new_system.bin" +sha256 = "1d58a8f4bb60f66009bd6df32e6e860c545c0c668de955af60d049ee3b9a212d" +name = "v1.0 factory image (built Jun 29 2026)" +notes = "Official factory image. Once online, the box registers for OTA and updates itself." diff --git a/firmware/new_system.bin b/firmware/new_system.bin new file mode 100644 index 0000000..730ce4b Binary files /dev/null and b/firmware/new_system.bin differ diff --git a/kota_tech/__init__.py b/kota_tech/__init__.py index cc64a07..e3f79e4 100644 --- a/kota_tech/__init__.py +++ b/kota_tech/__init__.py @@ -1,3 +1,3 @@ """KOTA sensorbox technician tool.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/kota_tech/diag/guides.toml b/kota_tech/diag/guides.toml index 2983cc7..6999aca 100644 --- a/kota_tech/diag/guides.toml +++ b/kota_tech/diag/guides.toml @@ -64,7 +64,7 @@ title = "Firmware is not running normally" text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen." actions = [ "Switch the box off and on with the power switch.", - "If it happens again: save the session log and re-flash the official firmware (Flash tab, coming in v0.4).", + "If it happens again: save the session log and re-flash the official firmware (Flash firmware tab, App only).", ] # =============================================================== network @@ -597,7 +597,7 @@ title = "Box does not start" text = "Neither LED is lit: no power or the firmware does not start." actions = [ "Check the power supply and cable.", - "If the USB port shows up in this tool, re-flash the official firmware (Flash tab, coming in v0.4).", + "If the USB port shows up in this tool, re-flash the official firmware (Flash firmware tab, App only).", ] # =================================================================== clock @@ -651,29 +651,48 @@ default = "clap" [[guide.step]] id = "switch" type = "question" -text = "The firmware reads the microphone as switched off. Does this box have a microphone ON/OFF switch or jumper (inside the case)?" +text = "The box reads its microphone switch as OFF. The switch is on the back of the case, just above the power switch (no need to open the box). Should this box measure noise?" options = [ - { label = "Yes - I set it to ON", next = "switch_verify" }, - { label = "No such switch / not sure", next = "end:mic_enable_line" }, - { label = "It is meant to be off", next = "end:mic_off_ok" }, + { label = "Yes - I flipped the microphone switch", next = "switch_verify" }, + { label = "No - it is meant to be off", next = "end:mic_off_ok" }, ] [[guide.step]] id = "switch_verify" type = "wait" -text = "The tool restarts the box and waits for a microphone reading (up to 60 s)..." -tool_action = "reset" +# The firmware re-reads the switch before every reading (~10 s): no restart needed. +text = "Waiting for the next microphone reading (up to 30 s)..." until = 'MICROPHONE: Read \d+ bytes' fail_on = 'Microphone is in shutdown state' -timeout_s = 60 +timeout_s = 30 on_ok = "end:mic_on" -on_fail = "end:mic_enable_line" -on_timeout = "end:mic_enable_line" +on_fail = "flip_back" +on_timeout = "flip_back" + +[[guide.step]] +id = "flip_back" +type = "question" +text = "The box still reads the switch as OFF. Flip the microphone switch to the other position (it may have been ON already)." +options = [ + { label = "Done - I flipped it to the other position", next = "switch_verify2" }, + { label = "Leave it off", next = "end:mic_off_ok" }, +] + +[[guide.step]] +id = "switch_verify2" +type = "wait" +text = "Waiting for the next microphone reading (up to 30 s)..." +until = 'MICROPHONE: Read \d+ bytes' +fail_on = 'Microphone is in shutdown state' +timeout_s = 30 +on_ok = "end:mic_on" +on_fail = "end:mic_switch_faulty" +on_timeout = "end:mic_switch_faulty" [[guide.step]] id = "clap" type = "wait" -text = "Clap your hands or speak loudly right next to the box for about 20 seconds..." +text = "Check the microphone switch (back of the case, above the power switch) is ON. Then clap your hands or speak loudly right next to the box for about 20 seconds..." until = 'dB peak: (?!-inf)' timeout_s = 45 on_ok = "end:mic_ok" @@ -683,8 +702,8 @@ on_timeout = "end:mic_faulty" id = "mic_on" severity = "ok" title = "Microphone is on and recording" -text = "The firmware now reads audio samples." -actions = [] +text = "The firmware reads audio samples again." +actions = ["Leave the microphone switch in this position."] [[guide.end]] id = "mic_off_ok" @@ -694,11 +713,11 @@ text = "No noise data is recorded for this box; everything else works." actions = [] [[guide.end]] -id = "mic_enable_line" +id = "mic_switch_faulty" severity = "warning" -title = "Microphone enable line stays 'off'" -text = "The firmware keeps reading the microphone as switched off (GPIO3)." -actions = ["Check the microphone board and its cable/connector (inside the case); send the box MAC to the firmware team to confirm the hardware variant."] +title = "Microphone switch has no effect" +text = "The box reads the microphone as OFF in both switch positions (enable line GPIO3)." +actions = ["Check the switch and its cable to the main board (inside the case); replace the switch if needed."] [[guide.end]] id = "mic_ok" @@ -711,5 +730,5 @@ actions = [] id = "mic_faulty" severity = "error" title = "Microphone records nothing" -text = "Even loud noise next to the box gives no signal." +text = "Even loud noise next to the box gives no signal, although the switch is on." actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."] diff --git a/kota_tech/diag/rules.toml b/kota_tech/diag/rules.toml index f8b6b5a..489a624 100644 --- a/kota_tech/diag/rules.toml +++ b/kota_tech/diag/rules.toml @@ -1,572 +1,572 @@ -# Log signatures for the KOTA sensorbox firmware. -# -# Derived from the firmware source (log strings, Status::set_status calls) and -# checked against real captures (tests/fixtures). Patterns match the cleaned -# line text, not the tag: the deployed v1.0 build logs some messages under -# different tags than the source (e.g. SD mount under INFLUX_BUFFER). -# -# Fields: -# id unique, "." -# pattern Python regex (search). Named groups can be used in `title`. -# severity critical | error | warning | info | ignore -# ignore = never shown (benign ESP_LOGE lines, LED-only rules) -# title short technician-facing headline -# meaning what it means, in plain words -# actions what to do, in order (prefer no-unscrewing actions first) -# explained_by list of rule ids; while one of them is active this finding -# is shown as a consequence of it instead of a root cause -# milestone boot milestone reached when the line is seen -# clears finding ids resolved when this line is seen -# led status LED colour this line sets (firmware Status::set_status) -# led_from only change the LED if it currently has this colour -# (Status::set_status_conditional) -# split_by named group; one finding per distinct value (e.g. per sensor) -# min_count finding becomes active only after this many occurrences -# min_uptime_ms ignore occurrences earlier than this uptime - -# ---------------------------------------------------------------- milestones - -[[rule]] -id = "boot.app_started" -pattern = 'app_init: Project name:\s+kota-sensorboard' -severity = "ignore" -milestone = "app_started" -led = "GREEN" # Status::init() -> STATUS_OK - -[[rule]] -id = "boot.sd_mounted" -pattern = 'SD card mounted successfully' -severity = "ignore" -milestone = "sd_mounted" - -[[rule]] -id = "boot.env_loaded" -pattern = 'Read INFLUX_URL from \.env file' -severity = "ignore" -milestone = "env_loaded" - -[[rule]] -id = "boot.matter_started" -pattern = 'ESP-Matter started successfully' -severity = "ignore" -milestone = "matter_started" - -[[rule]] -id = "boot.local_wifi_mode" -pattern = 'SSID/PASSWORD found in \.env' -severity = "ignore" -milestone = "local_wifi_mode" - -[[rule]] -id = "boot.sensors_ready" -# DeviceStatus::init installs the temperature sensor; it is the last init -# step, and any earlier init failure aborts before reaching it. -pattern = 'temperature_sensor: Range \[' -severity = "ignore" -milestone = "sensors_ready" - -[[rule]] -id = "net.wifi_connected" -pattern = 'sta ip: \d|got ip:|WIFI_SERVICE: Got IP address|IPv4 address changed|Interface IP Address changed' -severity = "ignore" -milestone = "wifi_connected" -clears = ["matter.not_commissioned", "wifi.disconnected"] - -[[rule]] -id = "matter.commissioned" -pattern = 'MAIN: Commissioning complete|MAIN: Fabric is committed' -severity = "ignore" -milestone = "matter_commissioned" -clears = ["matter.not_commissioned", "matter.commissioning_failed"] - -[[rule]] -id = "net.time_synced" -pattern = 'SNTP: Time synchronized' -severity = "ignore" -milestone = "time_synced" -clears = ["rtc.clock_not_running", "net.dns_fail"] - -[[rule]] -id = "influx.upload_ok" -pattern = 'INFLUX: HTTP request performed, status code: 2\d\d' -severity = "ignore" -milestone = "data_uploaded" -clears = ["influx.buffer_fail", "net.dns_fail", "net.tls_fail"] - -[[rule]] -id = "influx.write_ok" -pattern = 'Successfully wrote to Influx' -severity = "ignore" -milestone = "data_uploaded" -led = "GREEN" -led_from = "PURPLE" - -[[rule]] -id = "ota.registered" -pattern = 'Device registered successfully|OTA token loaded from NVS' -severity = "ignore" -milestone = "ota_registered" -clears = ["ota.registration_failed"] - -[[rule]] -id = "ota.checked" -pattern = 'OTA_CLIENT: No update available' -severity = "ignore" -milestone = "ota_checked" -clears = ["ota.check_failed"] -led = "GREEN" -led_from = "PURPLE" - -# ----------------------------------------------------------- benign E lines - -[[rule]] -id = "benign.mac" -pattern = 'ID: Device MAC address' -severity = "ignore" - -[[rule]] -id = "benign.attr_cb" -pattern = 'MAIN: Attribute update callback' -severity = "ignore" - -[[rule]] -id = "benign.zcl" -pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL' -severity = "ignore" - -[[rule]] -id = "benign.connect_chain" -# Transport-level echo of a failure that a more specific rule reports. -pattern = 'esp-tls: Failed to open new connection|transport_base: Failed to open a new connection|HTTP_CLIENT: Connection failed, sock < 0|INFLUX: Failed to perform HTTP request: 0,|INFLUX_BUFFER: Failed to write chunk to Influx|OTA_CLIENT: Error ESP_ERR_HTTP_CONNECT at' -severity = "ignore" - -# ------------------------------------------------------------------ network - -[[rule]] -id = "matter.not_commissioned" -pattern = 'CHIPoBLE advertising started|MAIN: Commissioning window opened' -severity = "warning" -title = "Box is not connected to Wi-Fi (not commissioned in Matter)" -meaning = "The box is waiting to be added to a Matter network over Bluetooth. Until then it has no Wi-Fi, so it cannot upload data or receive firmware updates." -actions = [ - "Add the box in the customer's Matter app (Apple Home, Google Home, ...) by scanning the QR sticker, or enter the manual code printed under it.", - "Use a 2.4 GHz Wi-Fi network; keep the phone close to the box during pairing.", - "If pairing keeps failing: hold the Matter button 5 s (factory reset) and try again.", -] -milestone = "ble_advertising" - -[[rule]] -id = "matter.commissioning_failed" -pattern = 'Commissioning failed, fail safe timer expired' -severity = "error" -title = "Matter pairing attempt failed" -meaning = "A phone started adding the box to Matter but did not finish in time." -actions = [ - "Retry pairing from the Matter app with the phone next to the box.", - "Check the Wi-Fi is 2.4 GHz and the password entered in the app is correct.", -] - -[[rule]] -id = "net.dns_fail" -pattern = '''couldn't get hostname for :(?P[\w.-]+)''' -severity = "error" -title = "No Internet: server names cannot be resolved" -meaning = "The box could not look up a server name. Either it has no Wi-Fi connection, or the network has no working Internet/DNS." -actions = [ - "Check the site Wi-Fi has Internet (open a website on a phone connected to the same network).", - "If the box was never paired: commission it in Matter first.", - "Check the router does not block new devices (guest isolation, MAC filtering).", -] -explained_by = ["matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] - -[[rule]] -id = "net.tls_fail" -pattern = 'mbedtls_ssl_handshake returned|Failed to verify peer certificate|esp-tls-mbedtls: .*[Hh]andshake' -severity = "error" -title = "Secure connection to the server failed" -meaning = "The box reached the server but the encrypted (HTTPS) connection failed. Common causes: wrong clock on the box, or a network that intercepts HTTPS (corporate proxy/firewall)." -actions = [ - "Try another network (e.g. phone hotspot) to rule out a firewall.", - "Check the clock finding below: HTTPS needs the correct date.", -] -explained_by = ["rtc.clock_not_running"] - -[[rule]] -id = "wifi.disconnected" -pattern = 'WiFi disconnected, retrying in' -severity = "error" -min_count = 3 -title = "Box cannot connect to the configured Wi-Fi" -meaning = "The Wi-Fi name/password from the SD card does not work: wrong password, network out of range, or not 2.4 GHz." -actions = [ - "Check SSID and PASSWORD in ENV.TXT (re-provision the SD card with the tool).", - "Make sure the network is 2.4 GHz and in range.", -] - -[[rule]] -id = "wifi.setup_portal" -pattern = 'WiFi provisioning portal ready: connect to "(?P[^"]+)"' -severity = "warning" -title = "Box is in Wi-Fi setup mode (network {ssid})" -meaning = "The box is hosting its own Wi-Fi network for configuration instead of connecting to the site Wi-Fi." -actions = [ - "Connect a phone to {ssid} and open http://sensorbox.local/ to enter the Wi-Fi details,", - "or short-press the Matter button (< 2 s) to leave setup mode.", -] -led = "WHITE" - -# ------------------------------------------------------------------- InfluxDB - -[[rule]] -id = "influx.buffer_fail" -pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer' -severity = "error" -title = "Measurements cannot be uploaded to InfluxDB" -meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple and the box restarts itself after about 2.5 minutes in this state." -actions = [ - "Fix the network problem first (see the finding this is grouped under).", - "Buffered data is uploaded automatically once the connection works.", -] -explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] - -[[rule]] -id = "influx.purple" -pattern = 'Failed to write chunk with \d+ retries to Influx|Failed to flush buffer before writing|Failed to append new entry to buffer' -severity = "ignore" -led = "PURPLE" - -[[rule]] -id = "influx.auth" -pattern = 'INFLUX: Failed to perform HTTP request: (?P401|403)' -severity = "critical" -title = "InfluxDB rejected the token (HTTP {code})" -meaning = "The box reaches the database but the INFLUX_TOKEN on the SD card is wrong or revoked." -actions = [ - "Re-provision the SD card with the current ENV.TXT (SD card is inside the case).", - "If all boxes show this: the shared token was rotated - update ENV.TXT for everyone.", -] - -[[rule]] -id = "influx.not_found" -pattern = 'INFLUX: Failed to perform HTTP request: (?P404)' -severity = "critical" -title = "InfluxDB bucket or organisation not found (HTTP 404)" -meaning = "INFLUX_URL on the SD card points to a bucket/org that does not exist." -actions = ["Re-provision the SD card with the correct ENV.TXT."] - -[[rule]] -id = "influx.server_error" -pattern = 'INFLUX: Failed to perform HTTP request: (?P5\d\d)' -severity = "warning" -title = "InfluxDB server error (HTTP {code})" -meaning = "The database server has a problem. The box is fine; data is buffered on the SD card." -actions = ["Nothing to fix on the box. Report to the backend team if it persists."] - -[[rule]] -id = "influx.bad_request" -pattern = 'INFLUX: Bad request, check line protocol format' -severity = "warning" -title = "InfluxDB refused some measurements (HTTP 400)" -meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)." -actions = ["Save the session log and send it to the firmware team."] - -[[rule]] -id = "influx.env_missing" -split_by = "key" -pattern = 'Failed to read (?PINFLUX_URL|INFLUX_TOKEN) from \.env file' -severity = "critical" -title = "ENV.TXT on the SD card is missing {key}" -meaning = "The box cannot start without the InfluxDB settings; it shows white and restarts." -actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] - -[[rule]] -id = "influx.init_fail" -pattern = 'MAIN: Failed to initialize Influx buffer' -severity = "critical" -title = "Data upload could not be initialised" -meaning = "Start-up stopped because the InfluxDB settings could not be read from the SD card. The box restarts." -actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] -explained_by = ["influx.env_missing", "sd.mount_fail", "sd.card_fail", "sd.env_open_fail"] -led = "WHITE" - -# ----------------------------------------------------------------- SD card - -[[rule]] -id = "sd.card_fail" -pattern = 'Failed to initialize the card \((?P[^)]+)\)' -severity = "critical" -title = "SD card not detected ({err})" -meaning = "The SD card does not respond: missing, not seated, or damaged. The box shows white and restarts." -actions = [ - "Open the case and re-seat the SD card.", - "Try a freshly provisioned card.", -] -led = "WHITE" - -[[rule]] -id = "sd.mount_fail" -pattern = 'Failed to mount filesystem' -severity = "critical" -title = "SD card is not formatted correctly" -meaning = "The card responds but has no readable FAT32 file system." -actions = ["Re-provision the card with the tool (FAT32, MBR) - SD card is inside the case."] -led = "WHITE" - -[[rule]] -id = "sd.env_open_fail" -pattern = 'Failed to open \.env file for reading' -severity = "critical" -title = "ENV.TXT not found on the SD card" -meaning = "The card is readable but the configuration file is missing." -actions = ["Re-provision the card with the tool (SD card is inside the case)."] - -[[rule]] -id = "sd.write_fail" -pattern = 'Failed to (create buffer file|open buffer file for appending)' -severity = "error" -title = "Cannot write to the SD card" -meaning = "Offline data cannot be stored: the card is full, write-protected or failing." -actions = ["Replace / re-provision the SD card (inside the case)."] - -# --------------------------------------------------------------- RTC / I2C - -[[rule]] -id = "rtc.init_failed" -pattern = 'MAIN: Failed to initialize RTC: (?P\S+)' -severity = "critical" -title = "Clock chip (RTC) or I2C bus not responding ({err})" -meaning = "The RTC is the first chip the firmware talks to over I2C, so this also appears when the I2C cable between the boards is loose or broken. LED blue, the box restarts." -actions = [ - "Open the case and check the I2C cable/connector between the boards.", - "If the cable is fine: the RTC module is faulty.", -] -led = "BLUE" - -[[rule]] -id = "rtc.clock_not_running" -pattern = 'RTC is uninitialized, using current time as timestamp' -severity = "warning" -min_count = 5 -title = "Clock is not set - sensor readings are on hold" -meaning = "The clock chip answers but its clock is not running (it reads 1 Jan 2000; typical after its coin cell was removed or is empty), and there is no Internet time yet. Readings are held until the time is set, so no data is recorded." -actions = [ - "Give the box Internet access: the clock is set automatically on the first connection.", - "If this comes back after every power-off: replace the RTC coin cell (inside the case).", -] - -[[rule]] -id = "i2c.errors" -pattern = 'I2C (read|write) failed: (?P\S+)|Failed to add I2C device' -severity = "error" -min_count = 2 -title = "I2C bus communication errors" -meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor." -actions = ["Open the case and check the I2C cable/connectors between the boards."] - -# ------------------------------------------------------------------ sensors - -[[rule]] -id = "sensor.init_failed" -split_by = "sensor" -pattern = 'MAIN: Failed to initialize (?PBME688|SEN66|AS7341|Microphone|Flicker sensor|DeviceStatus): (?P.+)' -severity = "critical" -title = "{sensor} failed to start" -meaning = "Start-up stopped at the {sensor} sensor ({err}). LED red, the box restarts." -actions = [ - "Open the case and check the {sensor} connector/cable.", - "If several sensors fail in turn: check the shared I2C cable.", -] -led = "RED" - -[[rule]] -id = "sensor.selfcheck_failed" -split_by = "sensor" -pattern = '(?PBME688|SEN66|AS7341|Microphone|Flicker sensor) self check failed' -severity = "error" -title = "{sensor} failed the post-update self-check" -meaning = "After a firmware update the {sensor} did not work; the box rolls back to the previous firmware." -actions = ["Check the {sensor} connector (inside the case), then power-cycle."] - -[[rule]] -id = "sen66.not_ready" -pattern = 'SEN66: (Failed while awaiting sensor ready|Self-check failed: Sensor not ready|Failed to read measurement)' -severity = "error" -title = "Air-quality sensor (SEN66) not delivering data" -meaning = "The SEN66 answers but never reports a measurement." -actions = ["Check the SEN66 cable (inside the case); the sensor may be faulty."] - -[[rule]] -id = "bme688.read_fail" -pattern = 'Failed to get (BME688 )?data from BME688|No new data available from BME688' -severity = "warning" -min_count = 3 -title = "Temperature/humidity sensor (BME688) read errors" -meaning = "The BME688 repeatedly fails to return a reading." -actions = ["Check the BME688 connection (inside the case)."] - -[[rule]] -id = "as7341.read_fail" -pattern = 'Failed to read (spectral|basic counts) data' -severity = "warning" -min_count = 2 -title = "Light sensor (AS7341) read errors" -meaning = "The AS7341 repeatedly fails to return a reading." -actions = ["Check the AS7341 connection (inside the case)."] - -[[rule]] -id = "mic.silent" -pattern = 'MICROPHONE: .*dB peak: -inf' -severity = "warning" -min_count = 2 -title = "Microphone records pure silence" -meaning = "Every audio sample is zero. A real room is never perfectly silent, so the microphone or its wiring is likely faulty." -actions = [ - "Make some noise near the box and watch whether this finding keeps growing.", - "If it does: check the microphone board/cable (inside the case).", -] - -[[rule]] -id = "mic.read_fail" -pattern = 'Failed to read samples' -severity = "error" -title = "Microphone read error" -meaning = "The audio interface returned an error." -actions = ["Check the microphone board/cable (inside the case)."] - -[[rule]] -id = "gather.stalled" -pattern = 'tasks still running, waiting' -severity = "warning" -min_uptime_ms = 180000 -min_count = 5 -title = "Sensor readings are stuck" -meaning = "A sensor has not finished its reading for minutes (the first 2 minutes of warm-up are normal)." -actions = ["See the grouped cause, or note which sensor stopped logging and check its cable."] -explained_by = ["rtc.clock_not_running", "sen66.not_ready"] - -# ---------------------------------------------------------- OTA / firmware - -[[rule]] -id = "ota.registration_failed" -pattern = 'Device registration failed' -severity = "warning" -title = "Box is not registered for firmware updates" -meaning = "The box could not reach the update server (bin.kota-app.com) to register, so it will not receive new firmware." -actions = ["Fix the network problem; registration is retried automatically every few seconds."] -explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] - -[[rule]] -id = "ota.check_failed" -pattern = 'Failed to check for updates|OTA Update check failed with status code: (?P\d+)' -severity = "error" -title = "Firmware update check failed" -meaning = "The update server could not be reached or refused the request. LED purple." -actions = ["Fix the network problem; the check is retried every 15 minutes."] -explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected"] -led = "PURPLE" - -[[rule]] -id = "ota.unauthorized" -pattern = 'Unauthorized, clearing saved token' -severity = "warning" -title = "Update server did not accept the box - it will re-register" -meaning = "The saved update token was rejected; the box registers again automatically." -actions = ["Nothing to do unless it repeats. Then report the box MAC to the backend team."] - -[[rule]] -id = "ota.updating" -pattern = 'New update available, starting OTA update' -severity = "info" -title = "Firmware update is downloading - do not switch off" -meaning = "The box found new firmware and is installing it (LED yellow). It restarts when done." -actions = ["Wait. Do not switch the power off."] -milestone = "ota_update_started" -led = "YELLOW" - -[[rule]] -id = "ota.update_failed" -pattern = 'OTA update failed: (?P\S+)' -severity = "error" -title = "Firmware update failed ({err})" -meaning = "The download or installation was interrupted; the box keeps its current firmware and retries later." -actions = ["Check the network is stable; the update is retried automatically."] - -[[rule]] -id = "ota.update_ok" -pattern = 'OTA update successful, rebooting' -severity = "info" -title = "Firmware update installed - restarting" -meaning = "New firmware was installed. After the restart it runs a self-check (LED orange, ~2.5 min)." -actions = ["Wait for green."] -milestone = "ota_update_done" - -[[rule]] -id = "ota.selfcheck" -pattern = 'Device is in selfcheck mode' -severity = "info" -title = "Post-update self-check running (~2.5 min)" -meaning = "The new firmware is testing all sensors before it is accepted (LED orange)." -actions = ["Wait. Do not switch off."] -led = "ORANGE" - -[[rule]] -id = "ota.selfcheck_failed" -pattern = 'Failing selfcheck: (?P.+)' -severity = "error" -title = "Update self-check failed: {reason}" -meaning = "The new firmware rejected itself; the box rolls back to the previous version." -actions = ["Check the named sensor (inside the case), then power-cycle."] -led = "RED" - -[[rule]] -id = "ota.selfcheck_passed" -pattern = 'Passing selfcheck' -severity = "ignore" -milestone = "ota_update_accepted" -led = "GREEN" - -[[rule]] -id = "ota.reset_during_update" -pattern = 'Device was reset during update' -severity = "warning" -title = "Box was switched off during a firmware update" -meaning = "An update was interrupted; the box reports it and retries." -actions = ["Leave the box powered while the LED is yellow or orange."] - -# --------------------------------------------------------------- system - -[[rule]] -id = "sys.out_of_resources" -pattern = 'Failed to create (task for sensor|gather task|main gather task)' -severity = "error" -title = "Firmware ran out of memory" -meaning = "The firmware could not start an internal task (LED red); it restarts itself after 30 s." -actions = ["Power-cycle. If it repeats, save the session log for the firmware team."] -led = "RED" - -[[rule]] -id = "sys.factory_reset" -pattern = 'Factory reset button pressed|Factory resetting device' -severity = "warning" -title = "Factory reset triggered with the Matter button" -meaning = "The box forgets its Matter network and must be paired again with the QR sticker." -actions = ["Re-commission the box in the Matter app."] -led = "WHITE" - -[[rule]] -id = "mic.disabled" -pattern = 'MICROPHONE: Microphone is in shutdown state' -severity = "info" -title = "Microphone is switched off - no noise measurements" -meaning = "The firmware reads the microphone enable line (GPIO3) as 'off', which it treats as disabled on purpose. Noise levels are not recorded." -actions = ["If this box should measure noise: check the microphone enable switch/jumper and its wiring (inside the case)."] - -[[rule]] -id = "gather.empty_reading" -pattern = 'SENSOR_GATHERER: Failed to get line protocol from sensor' -severity = "warning" -min_count = 2 -title = "A sensor returned no data" -meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor." -actions = ["Check the log lines just before this message for the sensor name."] -explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"] +# Log signatures for the KOTA sensorbox firmware. +# +# Derived from the firmware source (log strings, Status::set_status calls) and +# checked against real captures (tests/fixtures). Patterns match the cleaned +# line text, not the tag: the deployed v1.0 build logs some messages under +# different tags than the source (e.g. SD mount under INFLUX_BUFFER). +# +# Fields: +# id unique, "." +# pattern Python regex (search). Named groups can be used in `title`. +# severity critical | error | warning | info | ignore +# ignore = never shown (benign ESP_LOGE lines, LED-only rules) +# title short technician-facing headline +# meaning what it means, in plain words +# actions what to do, in order (prefer no-unscrewing actions first) +# explained_by list of rule ids; while one of them is active this finding +# is shown as a consequence of it instead of a root cause +# milestone boot milestone reached when the line is seen +# clears finding ids resolved when this line is seen +# led status LED colour this line sets (firmware Status::set_status) +# led_from only change the LED if it currently has this colour +# (Status::set_status_conditional) +# split_by named group; one finding per distinct value (e.g. per sensor) +# min_count finding becomes active only after this many occurrences +# min_uptime_ms ignore occurrences earlier than this uptime + +# ---------------------------------------------------------------- milestones + +[[rule]] +id = "boot.app_started" +pattern = 'app_init: Project name:\s+kota-sensorboard' +severity = "ignore" +milestone = "app_started" +led = "GREEN" # Status::init() -> STATUS_OK + +[[rule]] +id = "boot.sd_mounted" +pattern = 'SD card mounted successfully' +severity = "ignore" +milestone = "sd_mounted" + +[[rule]] +id = "boot.env_loaded" +pattern = 'Read INFLUX_URL from \.env file' +severity = "ignore" +milestone = "env_loaded" + +[[rule]] +id = "boot.matter_started" +pattern = 'ESP-Matter started successfully' +severity = "ignore" +milestone = "matter_started" + +[[rule]] +id = "boot.local_wifi_mode" +pattern = 'SSID/PASSWORD found in \.env' +severity = "ignore" +milestone = "local_wifi_mode" + +[[rule]] +id = "boot.sensors_ready" +# DeviceStatus::init installs the temperature sensor; it is the last init +# step, and any earlier init failure aborts before reaching it. +pattern = 'temperature_sensor: Range \[' +severity = "ignore" +milestone = "sensors_ready" + +[[rule]] +id = "net.wifi_connected" +pattern = 'sta ip: \d|got ip:|WIFI_SERVICE: Got IP address|IPv4 address changed|Interface IP Address changed' +severity = "ignore" +milestone = "wifi_connected" +clears = ["matter.not_commissioned", "wifi.disconnected"] + +[[rule]] +id = "matter.commissioned" +pattern = 'MAIN: Commissioning complete|MAIN: Fabric is committed' +severity = "ignore" +milestone = "matter_commissioned" +clears = ["matter.not_commissioned", "matter.commissioning_failed"] + +[[rule]] +id = "net.time_synced" +pattern = 'SNTP: Time synchronized' +severity = "ignore" +milestone = "time_synced" +clears = ["rtc.clock_not_running", "net.dns_fail"] + +[[rule]] +id = "influx.upload_ok" +pattern = 'INFLUX: HTTP request performed, status code: 2\d\d' +severity = "ignore" +milestone = "data_uploaded" +clears = ["influx.buffer_fail", "net.dns_fail", "net.tls_fail"] + +[[rule]] +id = "influx.write_ok" +pattern = 'Successfully wrote to Influx' +severity = "ignore" +milestone = "data_uploaded" +led = "GREEN" +led_from = "PURPLE" + +[[rule]] +id = "ota.registered" +pattern = 'Device registered successfully|OTA token loaded from NVS' +severity = "ignore" +milestone = "ota_registered" +clears = ["ota.registration_failed"] + +[[rule]] +id = "ota.checked" +pattern = 'OTA_CLIENT: No update available' +severity = "ignore" +milestone = "ota_checked" +clears = ["ota.check_failed"] +led = "GREEN" +led_from = "PURPLE" + +# ----------------------------------------------------------- benign E lines + +[[rule]] +id = "benign.mac" +pattern = 'ID: Device MAC address' +severity = "ignore" + +[[rule]] +id = "benign.attr_cb" +pattern = 'MAIN: Attribute update callback' +severity = "ignore" + +[[rule]] +id = "benign.zcl" +pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL' +severity = "ignore" + +[[rule]] +id = "benign.connect_chain" +# Transport-level echo of a failure that a more specific rule reports. +pattern = 'esp-tls: Failed to open new connection|transport_base: Failed to open a new connection|HTTP_CLIENT: Connection failed, sock < 0|INFLUX: Failed to perform HTTP request: 0,|INFLUX_BUFFER: Failed to write chunk to Influx|OTA_CLIENT: Error ESP_ERR_HTTP_CONNECT at' +severity = "ignore" + +# ------------------------------------------------------------------ network + +[[rule]] +id = "matter.not_commissioned" +pattern = 'CHIPoBLE advertising started|MAIN: Commissioning window opened' +severity = "warning" +title = "Box is not connected to Wi-Fi (not commissioned in Matter)" +meaning = "The box is waiting to be added to a Matter network over Bluetooth. Until then it has no Wi-Fi, so it cannot upload data or receive firmware updates." +actions = [ + "Add the box in the customer's Matter app (Apple Home, Google Home, ...) by scanning the QR sticker, or enter the manual code printed under it.", + "Use a 2.4 GHz Wi-Fi network; keep the phone close to the box during pairing.", + "If pairing keeps failing: hold the Matter button 5 s (factory reset) and try again.", +] +milestone = "ble_advertising" + +[[rule]] +id = "matter.commissioning_failed" +pattern = 'Commissioning failed, fail safe timer expired' +severity = "error" +title = "Matter pairing attempt failed" +meaning = "A phone started adding the box to Matter but did not finish in time." +actions = [ + "Retry pairing from the Matter app with the phone next to the box.", + "Check the Wi-Fi is 2.4 GHz and the password entered in the app is correct.", +] + +[[rule]] +id = "net.dns_fail" +pattern = '''couldn't get hostname for :(?P[\w.-]+)''' +severity = "error" +title = "No Internet: server names cannot be resolved" +meaning = "The box could not look up a server name. Either it has no Wi-Fi connection, or the network has no working Internet/DNS." +actions = [ + "Check the site Wi-Fi has Internet (open a website on a phone connected to the same network).", + "If the box was never paired: commission it in Matter first.", + "Check the router does not block new devices (guest isolation, MAC filtering).", +] +explained_by = ["matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] + +[[rule]] +id = "net.tls_fail" +pattern = 'mbedtls_ssl_handshake returned|Failed to verify peer certificate|esp-tls-mbedtls: .*[Hh]andshake' +severity = "error" +title = "Secure connection to the server failed" +meaning = "The box reached the server but the encrypted (HTTPS) connection failed. Common causes: wrong clock on the box, or a network that intercepts HTTPS (corporate proxy/firewall)." +actions = [ + "Try another network (e.g. phone hotspot) to rule out a firewall.", + "Check the clock finding below: HTTPS needs the correct date.", +] +explained_by = ["rtc.clock_not_running"] + +[[rule]] +id = "wifi.disconnected" +pattern = 'WiFi disconnected, retrying in' +severity = "error" +min_count = 3 +title = "Box cannot connect to the configured Wi-Fi" +meaning = "The Wi-Fi name/password from the SD card does not work: wrong password, network out of range, or not 2.4 GHz." +actions = [ + "Check SSID and PASSWORD in ENV.TXT (re-provision the SD card with the tool).", + "Make sure the network is 2.4 GHz and in range.", +] + +[[rule]] +id = "wifi.setup_portal" +pattern = 'WiFi provisioning portal ready: connect to "(?P[^"]+)"' +severity = "warning" +title = "Box is in Wi-Fi setup mode (network {ssid})" +meaning = "The box is hosting its own Wi-Fi network for configuration instead of connecting to the site Wi-Fi." +actions = [ + "Connect a phone to {ssid} and open http://sensorbox.local/ to enter the Wi-Fi details,", + "or short-press the Matter button (< 2 s) to leave setup mode.", +] +led = "WHITE" + +# ------------------------------------------------------------------- InfluxDB + +[[rule]] +id = "influx.buffer_fail" +pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer' +severity = "error" +title = "Measurements cannot be uploaded to InfluxDB" +meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple and the box restarts itself after about 2.5 minutes in this state." +actions = [ + "Fix the network problem first (see the finding this is grouped under).", + "Buffered data is uploaded automatically once the connection works.", +] +explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] + +[[rule]] +id = "influx.purple" +pattern = 'Failed to write chunk with \d+ retries to Influx|Failed to flush buffer before writing|Failed to append new entry to buffer' +severity = "ignore" +led = "PURPLE" + +[[rule]] +id = "influx.auth" +pattern = 'INFLUX: Failed to perform HTTP request: (?P401|403)' +severity = "critical" +title = "InfluxDB rejected the token (HTTP {code})" +meaning = "The box reaches the database but the INFLUX_TOKEN on the SD card is wrong or revoked." +actions = [ + "Re-provision the SD card with the current ENV.TXT (SD card is inside the case).", + "If all boxes show this: the shared token was rotated - update ENV.TXT for everyone.", +] + +[[rule]] +id = "influx.not_found" +pattern = 'INFLUX: Failed to perform HTTP request: (?P404)' +severity = "critical" +title = "InfluxDB bucket or organisation not found (HTTP 404)" +meaning = "INFLUX_URL on the SD card points to a bucket/org that does not exist." +actions = ["Re-provision the SD card with the correct ENV.TXT."] + +[[rule]] +id = "influx.server_error" +pattern = 'INFLUX: Failed to perform HTTP request: (?P5\d\d)' +severity = "warning" +title = "InfluxDB server error (HTTP {code})" +meaning = "The database server has a problem. The box is fine; data is buffered on the SD card." +actions = ["Nothing to fix on the box. Report to the backend team if it persists."] + +[[rule]] +id = "influx.bad_request" +pattern = 'INFLUX: Bad request, check line protocol format' +severity = "warning" +title = "InfluxDB refused some measurements (HTTP 400)" +meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)." +actions = ["Save the session log and send it to the firmware team."] + +[[rule]] +id = "influx.env_missing" +split_by = "key" +pattern = 'Failed to read (?PINFLUX_URL|INFLUX_TOKEN) from \.env file' +severity = "critical" +title = "ENV.TXT on the SD card is missing {key}" +meaning = "The box cannot start without the InfluxDB settings; it shows white and restarts." +actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] + +[[rule]] +id = "influx.init_fail" +pattern = 'MAIN: Failed to initialize Influx buffer' +severity = "critical" +title = "Data upload could not be initialised" +meaning = "Start-up stopped because the InfluxDB settings could not be read from the SD card. The box restarts." +actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] +explained_by = ["influx.env_missing", "sd.mount_fail", "sd.card_fail", "sd.env_open_fail"] +led = "WHITE" + +# ----------------------------------------------------------------- SD card + +[[rule]] +id = "sd.card_fail" +pattern = 'Failed to initialize the card \((?P[^)]+)\)' +severity = "critical" +title = "SD card not detected ({err})" +meaning = "The SD card does not respond: missing, not seated, or damaged. The box shows white and restarts." +actions = [ + "Open the case and re-seat the SD card.", + "Try a freshly provisioned card.", +] +led = "WHITE" + +[[rule]] +id = "sd.mount_fail" +pattern = 'Failed to mount filesystem' +severity = "critical" +title = "SD card is not formatted correctly" +meaning = "The card responds but has no readable FAT32 file system." +actions = ["Re-provision the card with the tool (FAT32, MBR) - SD card is inside the case."] +led = "WHITE" + +[[rule]] +id = "sd.env_open_fail" +pattern = 'Failed to open \.env file for reading' +severity = "critical" +title = "ENV.TXT not found on the SD card" +meaning = "The card is readable but the configuration file is missing." +actions = ["Re-provision the card with the tool (SD card is inside the case)."] + +[[rule]] +id = "sd.write_fail" +pattern = 'Failed to (create buffer file|open buffer file for appending)' +severity = "error" +title = "Cannot write to the SD card" +meaning = "Offline data cannot be stored: the card is full, write-protected or failing." +actions = ["Replace / re-provision the SD card (inside the case)."] + +# --------------------------------------------------------------- RTC / I2C + +[[rule]] +id = "rtc.init_failed" +pattern = 'MAIN: Failed to initialize RTC: (?P\S+)' +severity = "critical" +title = "Clock chip (RTC) or I2C bus not responding ({err})" +meaning = "The RTC is the first chip the firmware talks to over I2C, so this also appears when the I2C cable between the boards is loose or broken. LED blue, the box restarts." +actions = [ + "Open the case and check the I2C cable/connector between the boards.", + "If the cable is fine: the RTC module is faulty.", +] +led = "BLUE" + +[[rule]] +id = "rtc.clock_not_running" +pattern = 'RTC is uninitialized, using current time as timestamp' +severity = "warning" +min_count = 5 +title = "Clock is not set - sensor readings are on hold" +meaning = "The clock chip answers but its clock is not running (it reads 1 Jan 2000; typical after its coin cell was removed or is empty), and there is no Internet time yet. Readings are held until the time is set, so no data is recorded." +actions = [ + "Give the box Internet access: the clock is set automatically on the first connection.", + "If this comes back after every power-off: replace the RTC coin cell (inside the case).", +] + +[[rule]] +id = "i2c.errors" +pattern = 'I2C (read|write) failed: (?P\S+)|Failed to add I2C device' +severity = "error" +min_count = 2 +title = "I2C bus communication errors" +meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor." +actions = ["Open the case and check the I2C cable/connectors between the boards."] + +# ------------------------------------------------------------------ sensors + +[[rule]] +id = "sensor.init_failed" +split_by = "sensor" +pattern = 'MAIN: Failed to initialize (?PBME688|SEN66|AS7341|Microphone|Flicker sensor|DeviceStatus): (?P.+)' +severity = "critical" +title = "{sensor} failed to start" +meaning = "Start-up stopped at the {sensor} sensor ({err}). LED red, the box restarts." +actions = [ + "Open the case and check the {sensor} connector/cable.", + "If several sensors fail in turn: check the shared I2C cable.", +] +led = "RED" + +[[rule]] +id = "sensor.selfcheck_failed" +split_by = "sensor" +pattern = '(?PBME688|SEN66|AS7341|Microphone|Flicker sensor) self check failed' +severity = "error" +title = "{sensor} failed the post-update self-check" +meaning = "After a firmware update the {sensor} did not work; the box rolls back to the previous firmware." +actions = ["Check the {sensor} connector (inside the case), then power-cycle."] + +[[rule]] +id = "sen66.not_ready" +pattern = 'SEN66: (Failed while awaiting sensor ready|Self-check failed: Sensor not ready|Failed to read measurement)' +severity = "error" +title = "Air-quality sensor (SEN66) not delivering data" +meaning = "The SEN66 answers but never reports a measurement." +actions = ["Check the SEN66 cable (inside the case); the sensor may be faulty."] + +[[rule]] +id = "bme688.read_fail" +pattern = 'Failed to get (BME688 )?data from BME688|No new data available from BME688' +severity = "warning" +min_count = 3 +title = "Temperature/humidity sensor (BME688) read errors" +meaning = "The BME688 repeatedly fails to return a reading." +actions = ["Check the BME688 connection (inside the case)."] + +[[rule]] +id = "as7341.read_fail" +pattern = 'Failed to read (spectral|basic counts) data' +severity = "warning" +min_count = 2 +title = "Light sensor (AS7341) read errors" +meaning = "The AS7341 repeatedly fails to return a reading." +actions = ["Check the AS7341 connection (inside the case)."] + +[[rule]] +id = "mic.silent" +pattern = 'MICROPHONE: .*dB peak: -inf' +severity = "warning" +min_count = 2 +title = "Microphone records pure silence" +meaning = "Every audio sample is zero. A real room is never perfectly silent, so the microphone or its wiring is likely faulty." +actions = [ + "Make some noise near the box and watch whether this finding keeps growing.", + "If it does: check the microphone board/cable (inside the case).", +] + +[[rule]] +id = "mic.read_fail" +pattern = 'Failed to read samples' +severity = "error" +title = "Microphone read error" +meaning = "The audio interface returned an error." +actions = ["Check the microphone board/cable (inside the case)."] + +[[rule]] +id = "gather.stalled" +pattern = 'tasks still running, waiting' +severity = "warning" +min_uptime_ms = 180000 +min_count = 5 +title = "Sensor readings are stuck" +meaning = "A sensor has not finished its reading for minutes (the first 2 minutes of warm-up are normal)." +actions = ["See the grouped cause, or note which sensor stopped logging and check its cable."] +explained_by = ["rtc.clock_not_running", "sen66.not_ready"] + +# ---------------------------------------------------------- OTA / firmware + +[[rule]] +id = "ota.registration_failed" +pattern = 'Device registration failed' +severity = "warning" +title = "Box is not registered for firmware updates" +meaning = "The box could not reach the update server (bin.kota-app.com) to register, so it will not receive new firmware." +actions = ["Fix the network problem; registration is retried automatically every few seconds."] +explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] + +[[rule]] +id = "ota.check_failed" +pattern = 'Failed to check for updates|OTA Update check failed with status code: (?P\d+)' +severity = "error" +title = "Firmware update check failed" +meaning = "The update server could not be reached or refused the request. LED purple." +actions = ["Fix the network problem; the check is retried every 15 minutes."] +explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected"] +led = "PURPLE" + +[[rule]] +id = "ota.unauthorized" +pattern = 'Unauthorized, clearing saved token' +severity = "warning" +title = "Update server did not accept the box - it will re-register" +meaning = "The saved update token was rejected; the box registers again automatically." +actions = ["Nothing to do unless it repeats. Then report the box MAC to the backend team."] + +[[rule]] +id = "ota.updating" +pattern = 'New update available, starting OTA update' +severity = "info" +title = "Firmware update is downloading - do not switch off" +meaning = "The box found new firmware and is installing it (LED yellow). It restarts when done." +actions = ["Wait. Do not switch the power off."] +milestone = "ota_update_started" +led = "YELLOW" + +[[rule]] +id = "ota.update_failed" +pattern = 'OTA update failed: (?P\S+)' +severity = "error" +title = "Firmware update failed ({err})" +meaning = "The download or installation was interrupted; the box keeps its current firmware and retries later." +actions = ["Check the network is stable; the update is retried automatically."] + +[[rule]] +id = "ota.update_ok" +pattern = 'OTA update successful, rebooting' +severity = "info" +title = "Firmware update installed - restarting" +meaning = "New firmware was installed. After the restart it runs a self-check (LED orange, ~2.5 min)." +actions = ["Wait for green."] +milestone = "ota_update_done" + +[[rule]] +id = "ota.selfcheck" +pattern = 'Device is in selfcheck mode' +severity = "info" +title = "Post-update self-check running (~2.5 min)" +meaning = "The new firmware is testing all sensors before it is accepted (LED orange)." +actions = ["Wait. Do not switch off."] +led = "ORANGE" + +[[rule]] +id = "ota.selfcheck_failed" +pattern = 'Failing selfcheck: (?P.+)' +severity = "error" +title = "Update self-check failed: {reason}" +meaning = "The new firmware rejected itself; the box rolls back to the previous version." +actions = ["Check the named sensor (inside the case), then power-cycle."] +led = "RED" + +[[rule]] +id = "ota.selfcheck_passed" +pattern = 'Passing selfcheck' +severity = "ignore" +milestone = "ota_update_accepted" +led = "GREEN" + +[[rule]] +id = "ota.reset_during_update" +pattern = 'Device was reset during update' +severity = "warning" +title = "Box was switched off during a firmware update" +meaning = "An update was interrupted; the box reports it and retries." +actions = ["Leave the box powered while the LED is yellow or orange."] + +# --------------------------------------------------------------- system + +[[rule]] +id = "sys.out_of_resources" +pattern = 'Failed to create (task for sensor|gather task|main gather task)' +severity = "error" +title = "Firmware ran out of memory" +meaning = "The firmware could not start an internal task (LED red); it restarts itself after 30 s." +actions = ["Power-cycle. If it repeats, save the session log for the firmware team."] +led = "RED" + +[[rule]] +id = "sys.factory_reset" +pattern = 'Factory reset button pressed|Factory resetting device' +severity = "warning" +title = "Factory reset triggered with the Matter button" +meaning = "The box forgets its Matter network and must be paired again with the QR sticker." +actions = ["Re-commission the box in the Matter app."] +led = "WHITE" + +[[rule]] +id = "mic.disabled" +pattern = 'MICROPHONE: Microphone is in shutdown state' +severity = "info" +title = "Microphone is switched off - no noise measurements" +meaning = "The microphone switch on the back of the case (just above the power switch) is OFF, so noise levels are not recorded." +actions = ["If this box should measure noise: flip the microphone switch (back of the case, above the power switch). No restart needed - it is picked up within about 10 s."] + +[[rule]] +id = "gather.empty_reading" +pattern = 'SENSOR_GATHERER: Failed to get line protocol from sensor' +severity = "warning" +min_count = 2 +title = "A sensor returned no data" +meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor." +actions = ["Check the log lines just before this message for the sensor name."] +explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"] diff --git a/kota_tech/flash/__init__.py b/kota_tech/flash/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kota_tech/flash/image.py b/kota_tech/flash/image.py new file mode 100644 index 0000000..a12cbb6 --- /dev/null +++ b/kota_tech/flash/image.py @@ -0,0 +1,152 @@ +"""Official firmware images: validation and layout. + +Only images listed in firmware/firmware.toml with a matching SHA-256 are +offered for flashing: boxes normally get new firmware over OTA, so the tool +only ever writes the official factory image. + +The factory image is a merged ESP32-S3 image (bootloader at 0x0, partition +table at 0x8000, app at ota_0). App-only flashing takes the app out of it +and writes it to ota_0; the layout comes from the image's own partition +table, not from constants. +""" + +from __future__ import annotations + +import hashlib +import struct +import tomllib +from dataclasses import dataclass +from pathlib import Path + +FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware" +MANIFEST = FIRMWARE_DIR / "firmware.toml" + +PT_OFFSET = 0x8000 +PT_SIZE = 0xC00 +ESP32S3_CHIP_ID = 9 +IMAGE_MAGIC = 0xE9 +APP_DESC_MAGIC = 0xABCD5432 +FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"} + + +class ImageError(ValueError): + pass + + +@dataclass(frozen=True) +class Partition: + label: str + type: int + subtype: int + offset: int + size: int + + +@dataclass(frozen=True) +class AppInfo: + project: str + version: str + built: str + idf: str + elf_sha: str # hex + + +@dataclass(frozen=True) +class FirmwareImage: + path: Path + name: str + notes: str + sha256: str + flash_size: str + partitions: tuple[Partition, ...] + app: AppInfo + + @property + def data(self) -> bytes: + return self.path.read_bytes() + + def partition(self, label: str) -> Partition: + for p in self.partitions: + if p.label == label: + return p + raise ImageError(f"partition {label!r} not in image") + + @property + def partition_table(self) -> bytes: + return self.data[PT_OFFSET:PT_OFFSET + PT_SIZE] + + def app_bytes(self) -> bytes: + """The application image stored in the ota_0 region of the merged image.""" + # No stripping of trailing 0xFF: the image ends in a SHA-256 digest + # that may legitimately end in 0xFF. The merged file ends with the app. + ota0 = self.partition("ota_0") + return self.data[ota0.offset:ota0.offset + ota0.size] + + def blank_otadata(self) -> bytes: + """Erased otadata: the bootloader then boots ota_0.""" + return b"\xff" * self.partition("otadata").size + + +def parse_partition_table(raw: bytes) -> tuple[Partition, ...]: + parts = [] + for i in range(0, len(raw) - 31, 32): + entry = raw[i:i + 32] + magic = entry[:2] + if magic == b"\xaa\x50": + _, ptype, sub, off, size = struct.unpack_from(" AppInfo: + if len(data) < offset + 0x20 + 256 or data[offset] != IMAGE_MAGIC: + raise ImageError(f"no application image at 0x{offset:x}") + d = data[offset + 0x20:offset + 0x20 + 256] + if struct.unpack_from(" FirmwareImage: + data = path.read_bytes() + if len(data) < PT_OFFSET + PT_SIZE or data[0] != IMAGE_MAGIC: + raise ImageError("not a merged ESP32 image (no bootloader at offset 0)") + chip_id = struct.unpack_from("> 4, "?") + parts = parse_partition_table(data[PT_OFFSET:PT_OFFSET + PT_SIZE]) + labels = {p.label for p in parts} + for need in ("nvs", "otadata", "ota_0"): + if need not in labels: + raise ImageError(f"partition table has no {need!r} partition") + ota0 = next(p for p in parts if p.label == "ota_0") + app = _app_info(data, ota0.offset) + if app.project != "kota-sensorboard": + raise ImageError(f"image is {app.project!r}, not kota-sensorboard") + return FirmwareImage(path, name or path.name, notes, hashlib.sha256(data).hexdigest(), + flash_size, parts, app) + + +def official_images(manifest: Path = MANIFEST) -> list[FirmwareImage]: + """Images from the manifest whose file exists and matches its SHA-256.""" + if not manifest.exists(): + return [] + entries = tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []) + out = [] + for e in entries: + path = manifest.parent / e["file"] + if not path.exists(): + continue + img = inspect_image(path, e.get("name", ""), e.get("notes", "")) + if img.sha256 != e["sha256"].lower(): + raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt") + out.append(img) + return out diff --git a/kota_tech/flash/plan.py b/kota_tech/flash/plan.py new file mode 100644 index 0000000..ba6e99d --- /dev/null +++ b/kota_tech/flash/plan.py @@ -0,0 +1,101 @@ +"""Flash plans: which esptool steps run, in which order. + +App-only (default, repairs): check the box has the same partition table, +then write the app to ota_0 and erase otadata so ota_0 boots. NVS is kept: +Matter pairing, OTA registration and Wi-Fi survive. + +Full (new or wiped boards): write the whole factory image at 0x0, the same +as the factory flash_all.bat. This erases NVS: pairing and OTA registration +are lost. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from pathlib import Path + +from .image import PT_OFFSET, PT_SIZE, FirmwareImage + +BAUD = "460800" + + +class Mode(str, enum.Enum): + APP_ONLY = "app_only" + FULL = "full" + + +@dataclass +class Step: + kind: str # "backup" | "read_pt" | "write" + title: str + args: list[str] # esptool arguments (no interpreter prefix) + est_s: int # rough duration for the UI + output: Path | None = None # file produced by the step (backup / table) + + +@dataclass +class Plan: + mode: Mode + image: FirmwareImage + port: str + steps: list[Step] = field(default_factory=list) + workdir: Path | None = None + + def summary(self) -> list[str]: + return [f"{i}. {s.title} (~{_dur(s.est_s)})" for i, s in enumerate(self.steps, 1)] + + +def _dur(s: int) -> str: + return f"{s // 60} min" if s >= 90 else f"{s} s" + + +def _common(port: str, after: str) -> list[str]: + return ["--chip", "esp32s3", "--port", port, "--baud", BAUD, + "--before", "default_reset", "--after", after] + + +def build_plan(image: FirmwareImage, mode: Mode, port: str, workdir: Path, + backup_to: Path | None = None) -> Plan: + workdir.mkdir(parents=True, exist_ok=True) + plan = Plan(mode, image, port, workdir=workdir) + if backup_to is not None: + size = {"4MB": "0x400000", "8MB": "0x800000", "16MB": "0x1000000"}.get(image.flash_size, "0x800000") + plan.steps.append(Step( + "backup", f"Back up the box's complete flash to {backup_to.name}", + _common(port, "no_reset") + ["read_flash", "0", size, str(backup_to)], + est_s=200 if size == "0x800000" else 100, output=backup_to)) + + write_opts = ["write_flash", "--flash_mode", "keep", "--flash_freq", "keep", "--flash_size", "keep"] + if mode == Mode.APP_ONLY: + pt_file = workdir / "box_partition_table.bin" + plan.steps.append(Step( + "read_pt", "Check the box has the same partition layout", + _common(port, "no_reset") + ["read_flash", hex(PT_OFFSET), hex(PT_SIZE), str(pt_file)], + est_s=5, output=pt_file)) + app_file = workdir / "app_ota0.bin" + ota_file = workdir / "otadata_blank.bin" + app_file.write_bytes(image.app_bytes()) + ota_file.write_bytes(image.blank_otadata()) + otadata, ota0 = image.partition("otadata"), image.partition("ota_0") + plan.steps.append(Step( + "write", "Write the firmware app (keeps pairing, OTA registration, settings)", + _common(port, "hard_reset") + write_opts + [ + hex(otadata.offset), str(ota_file), hex(ota0.offset), str(app_file)], + est_s=20)) + else: + plan.steps.append(Step( + "write", "Write the complete factory image (erases pairing and OTA registration)", + _common(port, "hard_reset") + write_opts + ["0x0", str(image.path)], + est_s=25)) + return plan + + +def check_partition_table(box_table: bytes, image: FirmwareImage) -> str | None: + """None if App-only is safe, otherwise the reason it is not.""" + if box_table.strip(b"\xff") == b"": + return "The box has no partition table (blank or erased chip). Use Full mode." + if box_table != image.partition_table: + return ("The box uses a different partition layout than this image. " + "App-only could leave it unbootable - use Full mode.") + return None diff --git a/kota_tech/flash/runner.py b/kota_tech/flash/runner.py new file mode 100644 index 0000000..ae596f6 --- /dev/null +++ b/kota_tech/flash/runner.py @@ -0,0 +1,118 @@ +"""Runs a flash plan: one esptool subprocess per step, in a background thread. + +A subprocess (instead of calling esptool in-process) keeps esptool's +stdout/sys.exit handling away from the GUI and lets the tool read progress +line by line. Callbacks are invoked from the worker thread. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +import threading +from collections.abc import Callable +from dataclasses import dataclass + +from .plan import Plan, check_partition_table + +_PROGRESS = re.compile(r"\((\d{1,3}) ?%\)") + +# esptool output -> technician-facing hint +HINTS = [ + (re.compile(r"could not open port|Access is denied|PermissionError|Resource busy", re.I), + "The USB port is in use. Close other programs that use the sensorbox port (serial monitors, Arduino IDE) and try again."), + (re.compile(r"Failed to connect to ESP32-S3|No serial data received|Wrong boot mode", re.I), + "The box did not enter flashing mode by itself. Open the case, hold the BOOT button, press and release RESET, " + "release BOOT, then press Start again."), + (re.compile(r"device reports readiness to read but returned no data|The port disappeared|device disconnected", re.I), + "The USB connection dropped. Use a short data-capable USB cable directly on the laptop (no hub) and try again."), + (re.compile(r"Invalid head of packet|timed out", re.I), + "Communication errors. Try another USB cable or port."), +] + + +@dataclass +class Result: + ok: bool + message: str + hint: str = "" + failed_step: int | None = None + + +class FlashRunner: + def __init__(self, plan: Plan, + on_step: Callable[[int, str], None], + on_progress: Callable[[int], None], + on_output: Callable[[str], None], + on_done: Callable[[Result], None], + esptool_cmd: list[str] | None = None): + self.plan = plan + self._on_step, self._on_progress = on_step, on_progress + self._on_output, self._on_done = on_output, on_done + self._cmd = esptool_cmd or [sys.executable, "-m", "esptool"] + self._thread: threading.Thread | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._run, name="flash", daemon=True) + self._thread.start() + + def join(self, timeout: float | None = None) -> None: + if self._thread: + self._thread.join(timeout) + + def _run(self) -> None: + try: + self._on_done(self._execute()) + except Exception as exc: # never leave the UI waiting + self._on_done(Result(False, f"Internal error: {exc}")) + + def _execute(self) -> Result: + for i, step in enumerate(self.plan.steps): + self._on_step(i, step.title) + self._on_progress(0) + code, output = self._esptool(step.args) + if code != 0: + return Result(False, f"Step {i + 1} failed: {step.title}", _hint(output), i) + if step.kind == "read_pt": + problem = check_partition_table(step.output.read_bytes(), self.plan.image) + if problem: + return Result(False, problem, failed_step=i) + self._on_progress(100) + return Result(True, "Firmware written and verified by esptool.") + + def _esptool(self, args: list[str]) -> tuple[int, str]: + cmd = self._cmd + args + self._on_output("$ esptool " + " ".join(args)) + flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + creationflags=flags) + collected: list[str] = [] + buf = b"" + assert proc.stdout is not None + while chunk := proc.stdout.read1(4096): # returns as soon as bytes arrive + buf += chunk + # esptool redraws progress with '\r'; treat it as a line break. + *lines, buf = re.split(rb"[\r\n]", buf) + for raw in lines: + self._line(raw, collected) + if buf: + self._line(buf, collected) + return proc.wait(), "\n".join(collected) + + def _line(self, raw: bytes, collected: list[str]) -> None: + text = raw.decode("utf-8", "replace").strip() + if not text: + return + collected.append(text) + if m := _PROGRESS.search(text): + self._on_progress(min(int(m.group(1)), 100)) + return # progress lines are not worth listing one by one + self._on_output(text) + + +def _hint(output: str) -> str: + for pattern, hint in HINTS: + if pattern.search(output): + return hint + return "See the esptool output below. Save it and send it to the firmware team if the problem repeats." diff --git a/kota_tech/ui/cheatsheet.py b/kota_tech/ui/cheatsheet.py index c8cc492..269d615 100644 --- a/kota_tech/ui/cheatsheet.py +++ b/kota_tech/ui/cheatsheet.py @@ -40,7 +40,8 @@ HINTS = [ "reboot name the cause.", "Small LED next to the RGB LED blinks once per second while the " "firmware runs. Not blinking = firmware hung or not running.", - "Outside the case: power switch and the Matter button. Holding the " + "Outside the case (back): power switch, the microphone switch just above it " + "(off = no noise measurements), and the Matter button. Holding the " "Matter button 5 s factory-resets the box (it forgets its Matter " "network and must be commissioned again with the sticker QR code).", "Inside the case (unscrew): RESET and BOOT buttons and the SD card. " diff --git a/kota_tech/ui/flash_tab.py b/kota_tech/ui/flash_tab.py new file mode 100644 index 0000000..1253c7e --- /dev/null +++ b/kota_tech/ui/flash_tab.py @@ -0,0 +1,309 @@ +"""Flash firmware tab: official image only, App-only by default.""" + +from __future__ import annotations + +import datetime as dt +import tempfile +from pathlib import Path + +from PySide6.QtCore import QObject, Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, +) + +from ..core.session import SESSIONS_DIR +from ..flash.image import FirmwareImage, ImageError, official_images +from ..flash.plan import Mode, build_plan +from ..flash.runner import FlashRunner, Result +from ..serialio.ports import PortInfo, list_serial_ports + +BACKUP_DIR = SESSIONS_DIR.parent / "backups" +VERIFY_TIMEOUT_MS = 45_000 + +HELP = ( + "If flashing cannot connect: use a data-capable USB cable directly on the laptop, close " + "other programs using the port, then retry. Last resort (case must be opened): hold BOOT, " + "press and release RESET, release BOOT, and press Start again." +) + + +class _Bridge(QObject): + step = Signal(int, str) + progress = Signal(int) + output = Signal(str) + done = Signal(object) + + +class FlashTab(QWidget): + def __init__(self, serial_tab, parent: QWidget | None = None): + super().__init__(parent) + self.serial_tab = serial_tab + self._images: list[FirmwareImage] = [] + self._ports: list[PortInfo] = [] # before any signal can call _update() + self._runner: FlashRunner | None = None + self._port: PortInfo | None = None + self._image: FirmwareImage | None = None + + # 1. box + box = QGroupBox("1. Sensorbox") + self.port_combo = QComboBox(minimumWidth=360) + refresh = QPushButton("Refresh") + refresh.clicked.connect(self.refresh_ports) + self.box_fw = QLabel(wordWrap=True) + bl = QVBoxLayout(box) + row = QHBoxLayout() + row.addWidget(self.port_combo) + row.addWidget(refresh) + row.addStretch(1) + bl.addLayout(row) + bl.addWidget(self.box_fw) + + # 2. firmware + fwbox = QGroupBox("2. Firmware (official images only)") + self.image_combo = QComboBox(minimumWidth=360) + self.image_info = QLabel(wordWrap=True) + self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + fl = QVBoxLayout(fwbox) + fl.addWidget(self.image_combo) + fl.addWidget(self.image_info) + + # 3. mode + modebox = QGroupBox("3. What to write") + self.rb_app = QRadioButton("App only (recommended for repairs)") + self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)") + self.mode_group = QButtonGroup(self) + self.mode_group.addButton(self.rb_app) + self.mode_group.addButton(self.rb_full) + self.rb_app.setChecked(True) + app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the " + "settings stored on the box.", wordWrap=True) + full_note = QLabel("Writes everything like the factory. Erases the Matter pairing and the OTA " + "registration - the box must be paired again afterwards.", wordWrap=True) + for n in (app_note, full_note): + n.setContentsMargins(24, 0, 0, 6) + n.setStyleSheet("color:#555") + self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased") + self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)") + ml = QVBoxLayout(modebox) + for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup): + ml.addWidget(w) + + # 4. run + runbox = QGroupBox("4. Flash") + self.plan_label = QLabel(wordWrap=True) + self.start_btn = QPushButton("Start flashing") + self.start_btn.setMinimumHeight(36) + self.start_btn.clicked.connect(self._start) + self.step_label = QLabel() + self.bar = QProgressBar() + self.result = QLabel(wordWrap=True) + self.result.setMargin(8) + self.result.setTextFormat(Qt.TextFormat.RichText) + self.details_btn = QPushButton("Show esptool output") + self.details_btn.setCheckable(True) + self.output = QPlainTextEdit(readOnly=True) + self.output.setVisible(False) + self.details_btn.toggled.connect(self.output.setVisible) + rl = QVBoxLayout(runbox) + for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output): + rl.addWidget(w) + help_lbl = QLabel(HELP, wordWrap=True) + help_lbl.setStyleSheet("color:#555") + rl.addWidget(help_lbl) + rl.addStretch(1) # keep the run column packed at the top + + left = QVBoxLayout() + for w in (box, fwbox, modebox): + left.addWidget(w) + left.addStretch(1) + lay = QHBoxLayout(self) + lay.addLayout(left, 1) + lay.addWidget(runbox, 1) + + self._bridge = _Bridge() + self._bridge.step.connect(self._on_step) + self._bridge.progress.connect(self.bar.setValue) + self._bridge.output.connect(self.output.appendPlainText) + self._bridge.done.connect(self._on_done) + self._verify_timer = QTimer(self, interval=500) + self._verify_timer.timeout.connect(self._check_verify) + + for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled): + sig.connect(self._update) + self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full + self.image_combo.currentIndexChanged.connect(self._update) + self.port_combo.currentIndexChanged.connect(self._update) + + self._load_images() + self.refresh_ports() + self._show_result("", None) + + # ------------------------------------------------------------ setup + def _load_images(self) -> None: + try: + self._images = official_images() + except ImageError as exc: + self._images = [] + self.image_info.setText(f"Firmware file problem: {exc}") + self.image_combo.clear() + for img in self._images: + self.image_combo.addItem(img.name) + if not self._images: + self.image_combo.addItem("No official firmware image found in the firmware folder") + + def refresh_ports(self) -> None: + current = self.port_combo.currentData() + self._ports = list_serial_ports() + self.port_combo.clear() + for p in self._ports: + self.port_combo.addItem(p.label, p.device) + if not self._ports: + self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) + self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0)) + self._update() + + def showEvent(self, event): # noqa: N802 - refresh when the tab is opened + self.refresh_ports() + super().showEvent(event) + + # ------------------------------------------------------------ state + def _selected_image(self) -> FirmwareImage | None: + i = self.image_combo.currentIndex() + return self._images[i] if 0 <= i < len(self._images) else None + + def _selected_port(self) -> PortInfo | None: + dev = self.port_combo.currentData() + return next((p for p in self._ports if p.device == dev), None) + + def _update(self) -> None: + img = self._selected_image() + if img: + a = img.app + self.image_info.setText( + f"{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}
" + f"build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:12]}... verified
" + f"{img.notes}") + fw = self.serial_tab.engine.firmware + if fw.elf_sha and img: + same = fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha) + msg = f"Box currently runs: {fw.describe()}" + if same: + msg += "
It already has this firmware. Re-flash only if you suspect the firmware is damaged." + else: + msg += ("
The box runs a different build (probably updated over OTA). " + "Flashing puts the older factory image back; the box updates itself again once online.") + else: + msg = "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it." + self.box_fw.setText(msg) + + self.confirm_full.setVisible(self.rb_full.isChecked()) + ok = bool(img and self._selected_port() and self._runner is None + and (self.rb_app.isChecked() or self.confirm_full.isChecked())) + self.start_btn.setEnabled(ok) + if img and self._selected_port(): + mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL + steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else [] + if mode == Mode.APP_ONLY: + steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"] + else: + steps += ["Write the complete factory image (~25 s)"] + steps += ["Restart the box and verify the new firmware boots"] + self.plan_label.setText("Plan:
    " + "".join(f"
  1. {s}
  2. " for s in steps) + "
") + else: + self.plan_label.setText("Select a sensorbox and a firmware image.") + + # ------------------------------------------------------------ flash + def _start(self) -> None: + img, port = self._selected_image(), self._selected_port() + if not img or not port: + return + mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL + what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY + else "the COMPLETE factory image (pairing and OTA registration are erased)") + if QMessageBox.question(self, "Flash firmware", + f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n" + "Keep the box powered and the USB cable connected until it finishes.", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel + ) != QMessageBox.StandardButton.Ok: + return + backup_to = None + if self.backup.isChecked(): + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin" + plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to) + + self._port, self._image = port, img + self.serial_tab.release_port() # esptool needs the port exclusively + self.output.clear() + self.output.appendPlainText("\n".join(plan.summary())) + self._show_result("", None) + self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit, + self._bridge.output.emit, self._bridge.done.emit) + self._set_busy(True) + self._runner.start() + + def _set_busy(self, busy: bool) -> None: + for w in (self.port_combo, self.image_combo, self.rb_app, self.rb_full, self.backup, self.confirm_full): + w.setEnabled(not busy) + self._update() + + def _on_step(self, i: int, title: str) -> None: + n = len(self._runner.plan.steps) if self._runner else 0 + self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}") + + def _on_done(self, res: Result) -> None: + self._runner = None + if not res.ok: + self._set_busy(False) + self.step_label.setText("Stopped.") + hint = f"

{res.hint}

" if res.hint else "" + self._show_result(f"Flashing failed. {res.message}{hint}", "error") + self.details_btn.setChecked(True) + return + self.step_label.setText("Final step: restarting the box and checking which firmware it boots...") + self.bar.setValue(0) + self._show_result("Firmware written. Waiting for the box to start...", "info") + self.serial_tab.connect_port(self._port, capture=True) + self._verify_ms = 0 + self._verify_timer.start() + + def _check_verify(self) -> None: + self._verify_ms += self._verify_timer.interval() + self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS))) + fw = self.serial_tab.engine.firmware + want = self._image.app.elf_sha[:9] + done = fw.elf_sha is not None and fw.elf_sha.startswith(want) + if done or self._verify_ms >= VERIFY_TIMEOUT_MS: + self._verify_timer.stop() + self._set_busy(False) + self.bar.setValue(100) + if done: + self.step_label.setText("Done.") + self._show_result(f"Success. The box boots the new firmware (build {want}). " + "Check the Diagnosis panel in the Serial monitor.", "ok") + else: + seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen" + self._show_result(f"Written, but not verified. Expected build {want}, saw: {seen}. " + "Open the Serial monitor and press Reset & capture.", "warning") + + def _show_result(self, html: str, kind: str | None) -> None: + colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), + "warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")} + self.result.setVisible(bool(html)) + if html: + border, bg = colours[kind or "info"] + self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}") + self.result.setText(html) diff --git a/kota_tech/ui/main_window.py b/kota_tech/ui/main_window.py index 2107ad2..0076a87 100644 --- a/kota_tech/ui/main_window.py +++ b/kota_tech/ui/main_window.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal +from PySide6.QtCore import QCoreApplication, QObject, Qt, QTimer, QUrl, Signal from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import ( QCheckBox, @@ -30,6 +30,7 @@ from ..serialio.manager import SerialManager, State from ..serialio.ports import PortInfo, list_serial_ports from .cheatsheet import cheatsheet_html from .diag_panel import DiagnosticsPanel +from .flash_tab import FlashTab from .guide_panel import GuidePanel MAX_LINES = 20000 @@ -180,6 +181,9 @@ class SerialTab(QWidget): port = self._selected_port() if port is None: return + self._connect(port) + + def _connect(self, port: PortInfo) -> None: self._counts = {"E": 0, "W": 0, "boots": 0} self._engine.reset() self._runner.cancel() @@ -187,8 +191,36 @@ class SerialTab(QWidget): self._session.start(port.mac or port.device) self._manager.connect(port) + # ---- hand-off with the flash tab ------------------------------------ + @property + def engine(self) -> DiagnosticsEngine: + return self._engine + + def release_port(self) -> None: + """Stop monitoring so esptool can open the port exclusively.""" + if self._manager.is_running: + self._manager.disconnect() # joins the reader thread + QCoreApplication.processEvents() # deliver its queued final lines/state now + + def connect_port(self, port: PortInfo, capture: bool = True) -> None: + """Monitor `port`; with capture, restart the box once connected to record its boot.""" + self.release_port() + idx = self.port_combo.findData(port.device) + if idx < 0: + self.port_combo.addItem(port.label, port.device) + self._ports.append(port) + idx = self.port_combo.count() - 1 + self.port_combo.setCurrentIndex(idx) + self._reset_on_connect = capture + self._connect(port) + def _on_state(self, state: State, detail: str) -> None: running = state != State.DISCONNECTED + if not running and self._manager.is_running: + return # stale event from a session that was already replaced + if state == State.CONNECTED and getattr(self, "_reset_on_connect", False): + self._reset_on_connect = False + QTimer.singleShot(300, self._manager.reset_board) if not running and self._session.path is not None: self._session.write_report(self._engine.report().to_text()) self._session.stop() @@ -315,8 +347,10 @@ class MainWindow(QMainWindow): guide = QTextBrowser(openExternalLinks=True) guide.setHtml(cheatsheet_html()) self.serial_tab = SerialTab() + self.flash_tab = FlashTab(self.serial_tab) tabs.addTab(guide, "Quick start") tabs.addTab(self.serial_tab, "Serial monitor") + tabs.addTab(self.flash_tab, "Flash firmware") self.setCentralWidget(tabs) def closeEvent(self, event): # noqa: N802 (Qt naming) diff --git a/pyproject.toml b/pyproject.toml index 3fe9dc4..e5b2697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "kota-technician-tool" -version = "0.3.0" +version = "0.4.0" description = "KOTA sensorbox technician diagnostic tool (demo)" requires-python = ">=3.11,<3.14" dependencies = [ "pyserial>=3.5", "PySide6-Essentials>=6.7", + "esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands ] [dependency-groups] diff --git a/run_macos.command b/run_macos.command index b00e4f0..654deab 100755 --- a/run_macos.command +++ b/run_macos.command @@ -3,7 +3,7 @@ cd "$(dirname "$0")" || exit 1 # Keep the virtual environment out of the (Google Drive) tool folder. -export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.3" +export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.4" export PATH="$HOME/.local/bin:$PATH" if ! command -v uv >/dev/null 2>&1; then diff --git a/run_windows.bat b/run_windows.bat index 2466668..923ad5e 100644 --- a/run_windows.bat +++ b/run_windows.bat @@ -3,7 +3,7 @@ setlocal cd /d "%~dp0" rem Keep the virtual environment out of the (Google Drive) tool folder. -set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.3" +set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.4" set "PATH=%USERPROFILE%\.local\bin;%PATH%" where uv >nul 2>nul diff --git a/tests/test_flash.py b/tests/test_flash.py new file mode 100644 index 0000000..068a12d --- /dev/null +++ b/tests/test_flash.py @@ -0,0 +1,162 @@ +"""Flashing: image validation on the real official image, plan building, +and the runner against a fake esptool (no hardware).""" + +import hashlib +import sys +import threading +from pathlib import Path + +import pytest + +from kota_tech.flash.image import ImageError, inspect_image, official_images +from kota_tech.flash.plan import Mode, build_plan, check_partition_table +from kota_tech.flash.runner import FlashRunner + +FW = Path(__file__).resolve().parents[1] / "firmware" / "new_system.bin" + + +@pytest.fixture(scope="module") +def image(): + imgs = official_images() + assert len(imgs) == 1 + return imgs[0] + + +# ------------------------------------------------------------------ image + +def test_official_image_is_the_deployed_v1_build(image): + assert image.app.project == "kota-sensorboard" + assert image.app.elf_sha.startswith("45fa9d5b9") # same as the live board + assert image.flash_size == "8MB" + assert image.partition("ota_0").offset == 0x40000 + assert image.partition("otadata").offset == 0x31000 + assert image.partition("nvs").offset == 0x10000 + + +def test_app_bytes_are_a_complete_app_image(image): + app = image.app_bytes() + assert app[0] == 0xE9 + assert len(app) == len(image.data) - 0x40000 # ends where the file ends + assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF} + + +def test_modified_image_is_refused(tmp_path): + data = bytearray(FW.read_bytes()) + data[-1] ^= 0xFF + bad = tmp_path / "new_system.bin" + bad.write_bytes(data) + manifest = tmp_path / "firmware.toml" + manifest.write_text(f'[[image]]\nfile = "new_system.bin"\nsha256 = "{hashlib.sha256(FW.read_bytes()).hexdigest()}"\n') + with pytest.raises(ImageError, match="SHA-256"): + official_images(manifest) + + +@pytest.mark.parametrize("patch,msg", [ + ((0, 0x00), "not a merged"), + ((12, 0x05), "not ESP32-S3"), +]) +def test_wrong_images_are_rejected(tmp_path, patch, msg): + data = bytearray(FW.read_bytes()) + data[patch[0]] = patch[1] + p = tmp_path / "x.bin" + p.write_bytes(data) + with pytest.raises(ImageError, match=msg): + inspect_image(p) + + +# ------------------------------------------------------------------- plan + +def test_app_only_plan_keeps_nvs(image, tmp_path): + plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path) + kinds = [s.kind for s in plan.steps] + assert kinds == ["read_pt", "write"] + write = plan.steps[-1].args + offsets = [a for a in write if a.startswith("0x")] + assert offsets == ["0x31000", "0x40000"] # otadata + ota_0 only + assert "0x10000" not in write and "0x0" not in write # NVS / bootloader untouched + assert write[write.index("--after") + 1] == "hard_reset" + assert (tmp_path / "app_ota0.bin").read_bytes() == image.app_bytes() + + +def test_full_plan_with_backup(image, tmp_path): + backup = tmp_path / "bk.bin" + plan = build_plan(image, Mode.FULL, "COM5", tmp_path, backup_to=backup) + assert [s.kind for s in plan.steps] == ["backup", "write"] + assert plan.steps[0].args[-3:] == ["0", "0x800000", str(backup)] + assert plan.steps[0].args[plan.steps[0].args.index("--after") + 1] == "no_reset" + assert plan.steps[1].args[-2:] == ["0x0", str(image.path)] + assert "Back up" in plan.summary()[0] + + +def test_partition_table_check(image): + assert check_partition_table(image.partition_table, image) is None + assert "blank" in check_partition_table(b"\xff" * 0xC00, image) + other = bytearray(image.partition_table) + other[40] ^= 1 + assert "different partition layout" in check_partition_table(bytes(other), image) + + +# ----------------------------------------------------------------- runner + +FAKE = """ +import sys, pathlib +args = sys.argv[1:] +if "read_flash" in args: + out = pathlib.Path(args[-1]) + src = pathlib.Path(r"{fw}").read_bytes() + off, size = int(args[-3], 0), int(args[-2], 0) + {pt_line} + print("Read ... (50 %)", end="\\r"); print("Read ... (100 %)") + sys.exit(0) +if "write_flash" in args: + for p in (10, 60, 100): + print(f"Writing at 0x00040000... ({{p}} %)", end="\\r", flush=True) + print(); print("Hash of data verified.") + sys.exit({write_code}) +""" + + +def _fake(tmp_path, write_code=0, pt="same", fail_text=""): + pt_line = {"same": "out.write_bytes(src[off:off+size])", + "blank": "out.write_bytes(b'\\xff' * size)"}[pt] + script = FAKE.format(fw=FW, pt_line=pt_line, write_code=write_code) + if fail_text: + script = script.replace('print("Hash of data verified.")', f'print("{fail_text}")') + f = tmp_path / "fake_esptool.py" + f.write_text(script) + return [sys.executable, str(f)] + + +def _run(plan, cmd): + steps, progress, output, done = [], [], [], threading.Event() + result = {} + r = FlashRunner(plan, lambda i, t: steps.append(i), progress.append, output.append, + lambda res: (result.setdefault("r", res), done.set()), esptool_cmd=cmd) + r.start() + assert done.wait(30) + return result["r"], steps, progress, output + + +def test_runner_app_only_success(image, tmp_path): + plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path) + res, steps, progress, output = _run(plan, _fake(tmp_path)) + assert res.ok, res + assert steps == [0, 1] + assert 60 in progress and progress[-1] == 100 + assert any("Hash of data verified" in o for o in output) + + +def test_runner_refuses_app_only_on_blank_chip(image, tmp_path): + plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path) + res, steps, *_ = _run(plan, _fake(tmp_path, pt="blank")) + assert not res.ok and "Full mode" in res.message + assert steps == [0] # never reached the write step + + +def test_runner_reports_connect_failure_with_hint(image, tmp_path): + plan = build_plan(image, Mode.FULL, "COM5", tmp_path) + cmd = _fake(tmp_path, write_code=2, + fail_text="A fatal error occurred: Failed to connect to ESP32-S3: No serial data received.") + res, *_ = _run(plan, cmd) + assert not res.ok and res.failed_step == 0 + assert "BOOT" in res.hint diff --git a/tests/test_guides.py b/tests/test_guides.py index 3515285..ca2dc46 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -182,17 +182,30 @@ def test_reset_action_failure_is_shown(rig): assert "connect" in runner.view().note.lower() -def test_mic_disabled_switch_on_verified(rig): +def test_mic_switch_flip_verified_without_restart(rig): engine, runner, clock, actions, board, _ = rig _bench(engine) runner.start("mic") assert runner.step.id == "switch" - runner.answer("0") # set it to ON - assert actions == ["reset"] - board(*BOOT, "I (20000) MICROPHONE: Read 300000 bytes in 4936 ms") + assert "above the power switch" in runner.view().text + runner.answer("0") # flipped the external switch + assert actions == [] # the firmware re-reads it; no reset + board("I (95000) MICROPHONE: Read 300000 bytes in 4936 ms") assert runner.end.id == "mic_on" +def test_mic_switch_no_effect_in_both_positions(rig): + engine, runner, clock, actions, board, _ = rig + _bench(engine) + runner.start("mic") + runner.answer("0") + board("W (95000) MICROPHONE: Microphone is in shutdown state, skipping sample collection") + assert runner.step.id == "flip_back" + runner.answer("0") + board("W (105000) MICROPHONE: Microphone is in shutdown state, skipping sample collection") + assert runner.end.id == "mic_switch_faulty" + + def test_mic_clap_test_detects_dead_mic(rig): engine, runner, clock, _, board, _ = rig board(*BOOT, *["I (20036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, " diff --git a/uv.lock b/uv.lock index 85c01a9..7288a26 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,129 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.14" +resolution-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'darwin'", + "platform_machine != 'x86_64' or sys_platform != 'darwin'", +] + +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + +[[package]] +name = "bitarray" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f7/6765577df59e2345036e435f7e983e1c291d67b7d76a51918eff04ad1494/bitarray-3.11.0.tar.gz", hash = "sha256:bf19437ec00ec3d40aef82eaeedc14cf4000be9b635c4f5049796506e6630dd8", size = 174911, upload-time = "2026-09-01T15:21:52.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/55/aeb2eb3870a31257a29d3830f7d5804253d2070727ef6475629455491d33/bitarray-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:470940879d8553fecddc3cf306b7d4b69fa0f5b7a6e3790b238fe553578061c1", size = 170436, upload-time = "2026-09-01T15:19:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b4/38b6131ecd500f8f853367339b9e95ad4644d47729bcbf0fa2c5e4c06a91/bitarray-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56a2aa49e900aa927c147328e571da8a38a50ae99277ed093290845498dedefc", size = 166780, upload-time = "2026-09-01T15:19:55.173Z" }, + { url = "https://files.pythonhosted.org/packages/eb/28/d1e0493106911b08d3d328e463aa140ca76fcab6cdcda06912ffb59d53cf/bitarray-3.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55370f00c52b5e46800fee0218a27a1b99db6e09113c1b804190c039e4350ea1", size = 380740, upload-time = "2026-09-01T15:19:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/1ebb39e36b7102358942609583aa5a027b7e1eb83e140ad3fba86e53adba/bitarray-3.11.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:155205159f3c38180a705253341b8200414c0198e86a9b9e2db49010236ff785", size = 404566, upload-time = "2026-09-01T15:19:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/24/8d/e94bfa36a7cadcd16415afe531e0a01c6e2dc2bb11095c6c52fca384c642/bitarray-3.11.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c49fc5f57eea2e859a3f2936a1fed84510af6f7464572b641485de2d22148cb", size = 418899, upload-time = "2026-09-01T15:19:58.547Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3a/859196ab78c6fa0aa8f04155308dd314265c8c27e09350bcd3894fc333d8/bitarray-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41545b21384fd91767d294a5b3526c6306f7db577cf59ba72b51498e90bd52a7", size = 383414, upload-time = "2026-09-01T15:19:59.649Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/5fafae3bee337c31372293fcc6259960e9bcaf89825f07798514213c5686/bitarray-3.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:afe8ee837c085eeeaead62429f260a2de3a0602d1a34bcdead8b9f0388ef6114", size = 378658, upload-time = "2026-09-01T15:20:00.751Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/39a1e59453b99a67ee449d9d0c378f5e282ec4c00645127a9aaa4a5adba7/bitarray-3.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4bddbc76e619a36d2abad14713c4dd970a8fa46a75a85ebbb626a2f26f1821d8", size = 401990, upload-time = "2026-09-01T15:20:01.951Z" }, + { url = "https://files.pythonhosted.org/packages/87/aa/add67250e559e24fdc585921062e388395e0b47f40c6aa13e1ee493a13d4/bitarray-3.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:027766e71da6063cb51fcd4c51f031ac14d5088ecb55e4500ffd692d78630dd3", size = 399837, upload-time = "2026-09-01T15:20:03.202Z" }, + { url = "https://files.pythonhosted.org/packages/6e/80/c758fa566da3180e43fba1a9bc2c84b29fc5e25ce0149155378e89ee4172/bitarray-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:116a23f655f394f80f41fdc03368862485ac05534a56991eefb9f18f72e17f17", size = 380673, upload-time = "2026-09-01T15:20:04.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/fcb7bef53c2aacdbc2ca07f498e5249ab7d87dbfed666f184bb8d16fec0f/bitarray-3.11.0-cp311-cp311-win32.whl", hash = "sha256:30597fa6f4116eb6e374568d4f4de7e220462368ec7ce37d93403871bdf27a8c", size = 160605, upload-time = "2026-09-01T15:20:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/3d3f3f7846432bc1f630c99fa5cd595674b5318c77c774b6c028bdbf05ad/bitarray-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7b16df7ddef03e30622d894cce86a560b7888415ddcac470eb99940a5adf1a35", size = 171179, upload-time = "2026-09-01T15:20:06.579Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/c8cb10050d1afed77e0511861ba49d56724fd846b4a0b596f52e7ebe5711/bitarray-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:ec3d8b99a92220bdba382acf4eff370d6a6cda34a5ce936726cb9e8cf2427c47", size = 166363, upload-time = "2026-09-01T15:20:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/26/74/c878e209fd15fdf0460763199892fc4e74c6a7345f5161ad673af586f2fe/bitarray-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:93a7f9f9f848ec79f19fdd39f3b8f750d9abf8e65cd6fb3a045df192de23855d", size = 170727, upload-time = "2026-09-01T15:20:08.703Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f8/4463653a8c909796c8f8931d0fd64344c6745a144e432d0310435197b0ed/bitarray-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3889cf12ae0c04076772c8be66655bfe5925003b44dec056b569a68a121f7e93", size = 166746, upload-time = "2026-09-01T15:20:09.719Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/edaec4a0fffd04ee98b1425a597dc1d350c4def690b0a34815a75e5c2c33/bitarray-3.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:823638d0a900cea53c173fb31f5f1f4b913065a92fa3406d9b20eb7b7ebf189d", size = 383620, upload-time = "2026-09-01T15:20:10.78Z" }, + { url = "https://files.pythonhosted.org/packages/83/1d/8fba2e884d5e5e7fa6828b4ca461e819259bac0f6f99073388157ea1c6d6/bitarray-3.11.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:301763ffde9d288c5a58dda51c5290bc1081c2bc0faf266c4aef227ca59004d5", size = 406908, upload-time = "2026-09-01T15:20:12.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/71/414853d582267d8e397ea5bf894503236d81660c5d7a34b2a0d0ec6e9f2d/bitarray-3.11.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:664f02e6e2c63fcc1c02718d2daa21b04f77a18ef3850a9b348597c0c8a0f222", size = 422031, upload-time = "2026-09-01T15:20:13.157Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1c/35b3f34fee6f91f708eb81af03438afc137b2e5fc0e27ec11d948287f64c/bitarray-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52741a1d24a3723a8151259906cf1befeaf3d4ccf7d3e27e8be4334590b59bcd", size = 387692, upload-time = "2026-09-01T15:20:14.377Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7ebe3e04548cee84fba250425935a500ff02e7fef1b020ed182a3f5227c4/bitarray-3.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a1093eb1a756f08c7bdeb22969fb463aa62f036dc191ad371af5e292108c611", size = 381169, upload-time = "2026-09-01T15:20:15.515Z" }, + { url = "https://files.pythonhosted.org/packages/fe/48/cdf80b633f326451de801504f8718dd71ce63f534d29348822896d9c5bd2/bitarray-3.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ab0988829e4ed10fa971842e9fbf77595b8a4557a078a3589b0754f06417ddff", size = 404500, upload-time = "2026-09-01T15:20:16.804Z" }, + { url = "https://files.pythonhosted.org/packages/54/cf/a64cab1bb0f48101ac45fb45a7f0d000af76e26ed0ab26b52573140a9a64/bitarray-3.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c4cabc2439ab3ce76881037741bf41e9fafda217ff721fdee92701e5fe3c8b9c", size = 402903, upload-time = "2026-09-01T15:20:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/c3937572587a0503ea0a05ae61543916d6d93ffe48c26532cad9c4bfd9e4/bitarray-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9db61968efb834dacda6bcd7ef1f8a033253a53ac31426a684a28a7e4c9f2f29", size = 384582, upload-time = "2026-09-01T15:20:19.481Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/824648ef8f3fc14f56c4f9903e6c06979385f0c3a4d76371f6ccd8e9d1b2/bitarray-3.11.0-cp312-cp312-win32.whl", hash = "sha256:97cd5913000e4e56d211f7db053284d1f753aed34838368769bf084474a76f0a", size = 160902, upload-time = "2026-09-01T15:20:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c4/0a1608187e6d21b30c341ebcce6f75efbc8fe289f87ad58aa4149ce59828/bitarray-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:5db38c0a36d07190c3629c65dab2504233a56b1c35d8b1cc3d7369943d028711", size = 171542, upload-time = "2026-09-01T15:20:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/a8/59/f21b457eb6ab683ccf755093550ec1408e8ec8a66b980653c99740b85552/bitarray-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9aafe7dc6d568470f5c297d0b15acddd3afc1677c983f0460b28f8cac4e9360c", size = 166561, upload-time = "2026-09-01T15:20:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/578c4b63b3c1f206dfe7f7f60e941ca2d0aafd9da43943c076c01e976ec0/bitarray-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ac322a81c3c938c13093d8d77c419093f50118c29fbfa9eb80813d93af1a2d95", size = 170422, upload-time = "2026-09-01T15:20:24.673Z" }, + { url = "https://files.pythonhosted.org/packages/9a/30/3f982be25cf0c858351490b6d0cf6168e8668bb81e3daac25d8a3b2b8e44/bitarray-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ece8caff2fb31d6c69430d3dfc0efff70083e66b012f7f5de60cfe5dca98ba87", size = 166475, upload-time = "2026-09-01T15:20:25.649Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/f94c267873f0c80705dcef602af0a8d3f42838197e8fff01033f2a8084c9/bitarray-3.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:962dc3e5622a08bb1c1ad429a9f7dace7efadd7fef1fcdf1628a6db142333256", size = 381856, upload-time = "2026-09-01T15:20:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ce/e9ba5d080302c4d6fa269bccc7ad6ed4a5ad36a1dc75baae334714bbc183/bitarray-3.11.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de25b8c76fe9fe7d9c6bce2832096f020133e1d21e2841abd7280937bf90e56e", size = 405246, upload-time = "2026-09-01T15:20:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/cce77df63010304704eb802d884d9b11c84f63a02d7c4081a41225e25343/bitarray-3.11.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86dfaab663a2ad16ed2f6252116cf034cfc84282aa744f19a9a66542e85c9ca9", size = 420083, upload-time = "2026-09-01T15:20:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/2c7b1a995af9814b6ec5ee69333a0a6131924ed0f3f10ffbd73c22c0a6ad/bitarray-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5605c8e74f95f77dd1b93c8c83d6fefe0d41d2704683d4a72152ca5284e2546d", size = 385572, upload-time = "2026-09-01T15:20:30.604Z" }, + { url = "https://files.pythonhosted.org/packages/36/58/53845d1ddbe6077b61e7f39f586f7f35f18ce7dc47f178331e4fd64f59e1/bitarray-3.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d62e73fcdd273d07b1e9699de0e66e4d367b28c554c86bfe7c92029337c2a414", size = 379323, upload-time = "2026-09-01T15:20:31.69Z" }, + { url = "https://files.pythonhosted.org/packages/06/66/b67544886466ede3d26e58c43d9841cc427d4fdcd81ded4087313687241a/bitarray-3.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9808734d1e5fd2839660a9db1abfe93e9d64b2aa9ed59aa480193ad93e8d620e", size = 402087, upload-time = "2026-09-01T15:20:33.113Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/4ffc5984f3f4a12458adbceff60def49752f74807945771428f97694aec0/bitarray-3.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8b77aa18131418b869be882df1c5e229fb02f90861783bbe638d9ebe296e9209", size = 401000, upload-time = "2026-09-01T15:20:34.658Z" }, + { url = "https://files.pythonhosted.org/packages/e8/15/2d5dd4107e49b2c0040bc9cc48ee5af7f45772fedae3bc54fa987aeeedf2/bitarray-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d084fa59c94d3fec7eac6793e08162fa697aa4e70b9f519618c29d3e54eda752", size = 382431, upload-time = "2026-09-01T15:20:36.013Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8b/8d95483688bcf297654c87b65ef1847fad794adb083d294da65ac275d4fc/bitarray-3.11.0-cp313-cp313-win32.whl", hash = "sha256:387b64dc54be3732a0988bf3731fc06d7d4f52dc9470d1a5479e8ce2b67d368b", size = 160760, upload-time = "2026-09-01T15:20:37.241Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9b/b10ebf5e9a479815d2d54908617c0c55b255965389d3132927c9cd9ccf86/bitarray-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:b4afbb464a1db2d37858bd3b87bd59a76b4aae5e29ec007a9fd00bf5a911e1da", size = 171278, upload-time = "2026-09-01T15:20:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/16/3a/b0a29fc2379c567eb49730022c5913ad85b2f9ccda1b841737f756e1fb87/bitarray-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:36da946f64e10e28b23f345fde90f4d14e9c2e9013364fe04d9b2013e7291285", size = 166426, upload-time = "2026-09-01T15:20:39.862Z" }, +] + +[[package]] +name = "bitstring" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "tibs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/d3/de6fe4e7065df8c2f1ac1766f5fdccbe75bc18af2cf2dbeecd34d68e1518/bitstring-4.4.0.tar.gz", hash = "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37", size = 255209, upload-time = "2026-03-10T20:29:14.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/02/1a870bab76f2896d827aa4963be95e56675ffa1453e53525d13c43036edf/bitstring-4.4.0-py3-none-any.whl", hash = "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737", size = 76846, upload-time = "2026-03-10T20:29:12.832Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, +] [[package]] name = "colorama" @@ -11,6 +134,79 @@ 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 = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "esptool" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete", marker = "sys_platform != 'win32'" }, + { name = "bitstring" }, + { name = "cryptography" }, + { name = "ecdsa" }, + { name = "intelhex" }, + { name = "pyserial" }, + { name = "pyyaml" }, + { name = "reedsolo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" } + [[package]] name = "iniconfig" version = "2.3.0" @@ -20,11 +216,21 @@ 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 = "intelhex" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/37/1e7522494557d342a24cb236e2aec5d078fac8ed03ad4b61372586406b01/intelhex-2.3.0.tar.gz", hash = "sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093", size = 44513, upload-time = "2020-10-20T20:35:51.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/78/79461288da2b13ed0a13deb65c4ad1428acb674b95278fa9abf1cefe62a2/intelhex-2.3.0-py2.py3-none-any.whl", hash = "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", size = 50914, upload-time = "2020-10-20T20:35:50.162Z" }, +] + [[package]] name = "kota-technician-tool" -version = "0.1.0" +version = "0.4.0" source = { virtual = "." } dependencies = [ + { name = "esptool" }, { name = "pyserial" }, { name = "pyside6-essentials" }, ] @@ -36,6 +242,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "esptool", specifier = "~=4.8" }, { name = "pyserial", specifier = ">=3.5" }, { name = "pyside6-essentials", specifier = ">=6.7" }, ] @@ -61,6 +268,15 @@ 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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -110,6 +326,52 @@ 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 = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "reedsolo" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/61/a67338cbecf370d464e71b10e9a31355f909d6937c3a8d6b17dd5d5beb5e/reedsolo-1.7.0.tar.gz", hash = "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732", size = 59723, upload-time = "2023-01-17T05:10:19.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" }, +] + [[package]] name = "shiboken6" version = "6.11.2" @@ -121,3 +383,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" }, { url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" }, ] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tibs" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/cd/6cf028decf1c2df4d26077dd5d0532587d93d4917233d5e004133166a940/tibs-0.5.7.tar.gz", hash = "sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb", size = 78255, upload-time = "2026-03-12T13:06:29.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/2d/de2c579d3eea0f18212b5b16decb04568b7a0ef912d00581a77492609d4e/tibs-0.5.7-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c", size = 411352, upload-time = "2026-03-12T13:06:52.016Z" }, + { url = "https://files.pythonhosted.org/packages/74/71/4c21ccc5c2e1672f9cd91ed2c46604c250cffd9d386113772dded128b5cf/tibs-0.5.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44", size = 383971, upload-time = "2026-03-12T13:06:50.143Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/399940ac5393772792a209911a5efa42cf55cf621771e48b863211ac5a2a/tibs-0.5.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f", size = 416256, upload-time = "2026-03-12T13:06:24.222Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/481a73e74d398949f57d297b1809a10a951d252e7ec94b6715ed952ce500/tibs-0.5.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f", size = 428003, upload-time = "2026-03-12T13:06:23.064Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e0/72db1760a7f7fec1d5f3690e0855fbbccbcf0a4a2fd318c9d71f3b33f3a7/tibs-0.5.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98", size = 455589, upload-time = "2026-03-12T13:06:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/3e/26/9cd3395914bf705d6ae1e9a6c323f727e9dc88fef716327ce7f486e0b55a/tibs-0.5.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3", size = 459266, upload-time = "2026-03-12T13:06:21.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3b/267f19a008d13c704dc0b044138a56239272a43531ccb05464129d0fbd01/tibs-0.5.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41", size = 423466, upload-time = "2026-03-12T13:06:41.212Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d4/424ae3515e0e013ad83186074bf3beb53399b9052c00da703415ccc316ca/tibs-0.5.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e", size = 452080, upload-time = "2026-03-12T13:06:32.112Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/ab80beba83a134745439d33763e1d3b017f994abeb9c309a3ac9fd94e90e/tibs-0.5.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54", size = 592311, upload-time = "2026-03-12T13:06:47.807Z" }, + { url = "https://files.pythonhosted.org/packages/4c/21/f5cf41c15431e63aeaefb494e714d48d9e9061b4e01fcc01d1987e2e5faa/tibs-0.5.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392", size = 703400, upload-time = "2026-03-12T13:06:16.968Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ec/b3bdb7dcc3de8513c5678a685f4e25bb85ef48526d7d535ddc592f9e8602/tibs-0.5.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452", size = 664623, upload-time = "2026-03-12T13:06:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/7b85af3ad1b2cd9871c8f50ba0eb17e54e12481b467678535e58aced0d98/tibs-0.5.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b", size = 635199, upload-time = "2026-03-12T13:06:34.798Z" }, + { url = "https://files.pythonhosted.org/packages/b9/63/60220fb502beb857306afd4a5bac4a8617ae496f3b1f4968d127380fdefe/tibs-0.5.7-cp38-abi3-win32.whl", hash = "sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2", size = 288454, upload-time = "2026-03-12T13:06:30.978Z" }, + { url = "https://files.pythonhosted.org/packages/46/ab/aab78827ba7e0d65fe346b86d1d61e0792c38d5f9b7547e0f71b7027c835/tibs-0.5.7-cp38-abi3-win_amd64.whl", hash = "sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2", size = 304135, upload-time = "2026-03-12T13:06:35.884Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/e9e6a610928a4bcbf04f0ac1436ee320aa8cbe95181f1aa32687c50e858b/tibs-0.5.7-cp38-abi3-win_arm64.whl", hash = "sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7", size = 289272, upload-time = "2026-03-12T13:06:19.247Z" }, +]