diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index f613797..32656fa 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -32,7 +32,7 @@ body: description: Please specify the software and hardware you used to produce the bug. placeholder: | - SPARROW version / commit hash: - - Hardware: Jetson Orin Nano / other + - Hardware: Raspberry Pi / other - OS: Ubuntu 22.04 - Docker version: validations: diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index ec308d6..0000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Deploy MkDocs site - -on: - push: - branches: - - main - paths: - - 'docs/**' - - 'overrides/**' - - 'mkdocs.yml' - - 'docs-requirements.txt' - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install MkDocs dependencies - run: pip install -r docs-requirements.txt - - - name: Deploy to GitHub Pages - run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index 3eee500..d3dbbc4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,15 +3,6 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -# Docs build output (served from gh-pages branch) -site/ - -# Local archive directories -archive/ - -# Workspace files -*.code-workspace - # User-specific files *.rsuser *.suo @@ -425,3 +416,6 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# MacOS +.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b37121 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to SPARROW are documented in this file. + +This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2026-07-23 + +### Added + +- Added standalone contribution guidelines. +- Added a project changelog. + +### Changed + +- Replaced the legacy Jetson setup path with the Raspberry Pi 5 setup. +- Restored repository support, citation, and issue-template metadata. +- Removed committed runtime logs. +- Reverted XBee payload deduplication pending further validation. + +## 0.9.0 - 2026-07-13 + +### Added + +- Added SHA-256 deduplication for incoming XBee payload retransmissions. + +## 0.8.0 - 2026-07-13 + +### Changed + +- Updated both service containers to Python 3.12. +- Refreshed dependency pins affected by the Python upgrade. + +## 0.7.0 - 2026-07-13 + +### Changed + +- Updated the PyTorch, torchvision, and torchaudio dependency set. +- Pointed setup and updater flows at the public `microsoft/SPARROW` repository + and removed personal access token handling. +- Renamed the default hosted service endpoint to + `sparrowstudio.azure.com`. +- Refreshed the hardware assembly and bill-of-materials documentation. + +## 0.6.0 - 2026-06-10 + +### Added + +- Added support for receiving and uploading MP3 audio over XBee. +- Documented the automatic updater, rollback behavior, and operator controls. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a4f4b37 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to SPARROW + +Thank you for your interest in contributing to SPARROW. Contributions can +include bug fixes, documentation improvements, hardware compatibility updates, +and new capabilities for the SPARROW client. + +## Before you start + +- Search the [existing issues](https://github.com/microsoft/SPARROW/issues) to + avoid duplicating work. +- For bugs, include the SPARROW version or commit, hardware, operating system, + Docker version, logs with sensitive values removed, and reproducible steps. +- For substantial changes, open an issue before implementation so the approach + and hardware impact can be discussed. +- Report security vulnerabilities according to [SECURITY.md](SECURITY.md), not + through a public issue. + +## Development setup + +SPARROW targets Raspberry Pi 5 hardware and runs its services with Docker +Compose. Follow the setup and hardware guidance in [README.md](README.md) for a +complete deployment. + +To prepare a contribution: + +1. Fork the repository and clone your fork. +2. Create a branch from the latest `main`. +3. Make a focused change and update related documentation. +4. Validate the change using the checks below. +5. Open a pull request describing the problem, solution, and validation. + +Do not commit access keys, passwords, private certificates, deployment data, or +real field recordings and images. Remove personal data, precise deployment +locations, device identifiers, and credentials from logs and examples. + +## Validation + +The repository does not currently include an automated test suite. Run the +checks that apply to your change and describe any hardware validation in the +pull request. + +```bash +# Check Python syntax. +python3 -m compileall -q sparrow starlink + +# Validate the Compose configuration. +docker-compose config + +# Build the affected services on a supported Docker host. +docker-compose build sparrow starlink-tools + +# Check for whitespace errors. +git diff --check +``` + +Changes involving cameras, audio devices, environmental sensors, XBee radios, +Starlink, power management, or Raspberry Pi GPIO should be tested on the +relevant hardware when possible. If hardware validation is not possible, +clearly identify what remains untested. + +## Pull request checklist + +- Keep the change focused and avoid unrelated cleanup. +- Explain user-visible behavior changes and compatibility considerations. +- Update the README or other documentation when setup, configuration, hardware, + or operational behavior changes. +- Add notable changes to the next semantic version section in + [CHANGELOG.md](CHANGELOG.md). +- Confirm that no secrets, credentials, personal data, or deployment data are + included. +- Include validation commands and results in the pull request description. + +## Contributor License Agreement + +Most contributions require you to agree to a Contributor License Agreement +(CLA) declaring that you have the right to, and actually do, grant us the +rights to use your contribution. For details, visit +[Contributor License Agreements](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will determine whether you need to +provide a CLA and will add the appropriate status check or comment. Follow the +bot's instructions. You only need to complete this process once across +repositories that use the Microsoft CLA. + +## Code of Conduct + +This project follows the +[Microsoft Open Source Code of Conduct](CODE_OF_CONDUCT.md). For questions or +concerns, see the contact information in that document. diff --git a/README.md b/README.md index abb4417..32536cd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![SPARROW β€” Solar-Powered Acoustic and Remote Recording Observation Watch, edge AI for wildlife monitoring](https://zenodo.org/records/17547596/files/SPARROW-banner.png) +![image](https://zenodo.org/records/17547596/files/SPARROW-banner.png)

@@ -28,24 +28,6 @@ The resulting critical information is then transmitted via low-Earth orbit satel ---- - -## Part of the Microsoft Biodiversity Ecosystem - -| Repository | Description | -|---|---| -| [microsoft/Biodiversity](https://github.com/microsoft/Biodiversity) | Umbrella hub β€” PyTorch-Wildlife, MegaDetector, ecosystem overview | -| [microsoft/MegaDetector](https://github.com/microsoft/MegaDetector) | Animal/human/vehicle detector for camera traps | -| [microsoft/Pytorch-Wildlife](https://github.com/microsoft/Pytorch-Wildlife) | Unified AI framework: detection + species classification | -| [microsoft/MegaDetector-Acoustic](https://github.com/microsoft/MegaDetector-Acoustic) | Bioacoustic AI for audio-based wildlife detection and classification | -| [microsoft/MegaDetector-Overhead](https://github.com/microsoft/MegaDetector-Overhead) | Wildlife detection in aerial and drone imagery | -| [microsoft/MegaDetector-Sonar](https://github.com/microsoft/MegaDetector-Sonar) | Sonar-based wildlife detection for aquatic monitoring | -| [microsoft/MegaDetector-Classifier](https://github.com/microsoft/MegaDetector-Classifier) | Camera-trap species classification fine-tuning β€” adapt classifiers to your own datasets and geographic regions | -| [microsoft/SPARROW](https://github.com/microsoft/SPARROW) | This repo β€” solar-powered edge AI for wildlife monitoring | -| SPARROW Studio | Desktop application for all AI for Good Lab models | - ---- - # ✨ Key Features 1. **πŸ”‹ Autonomous operation** @@ -55,7 +37,7 @@ To learn more about project SPARROW, please checkout our SPARROW paper here πŸ‘‰ Camera traps, acoustic monitoring, and environmental sensors - SPARROW integrates multi-modal sensing to capture images, sounds, and enviromental metrics for comprehensive biodiversity monitoring. 3. **🧠 On-device AI** - Runs optimized PyTorch-Wildlife models on power efficient edge GPUs (e.g., Jetson Orin Nano) for real-time image and acoustic detection, species classification, and event recognition. + Runs optimized PyTorch Wildlife models (exported to ONNX) on a Raspberry Pi 5 via ONNXRuntime for real-time image and acoustic detection, species classification, and event recognition. 4. **🌐 Global connectivity** Even in the most remote ecosystems, SPARROW maintains a link to the cloud or on-premise infrastructure through low-Earth-orbit satellites, ensuring that vital conservation data reaches researchers in near real time. @@ -70,6 +52,14 @@ Data collection, on-device inference, power management, telemetry, and secure tr All services run in **Docker** and are orchestrated with **Docker Compose**. 🐳 +> [!NOTE] +> **Jetson compatibility:** SPARROW now targets Raspberry Pi 5. The last +> revision with NVIDIA Jetson support is +> [`59f7351`](https://github.com/microsoft/SPARROW/commit/59f7351). +> Users who prefer Jetson hardware can fork the repository and create a branch +> from that revision. Jetson-specific fixes made from that point are community +> maintained and are not included in the current Raspberry Pi release. + --- # πŸš€ Getting Started @@ -92,9 +82,9 @@ This curated list is designed to simplify the process for beginners and those wi | **System** | **Item** | **Description** | **Qty** | **Tested/Recommended** | | -------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Brain | NVIDIA Jetson Orin Nano Super Developer Kit | AI Edge Compute Device | 1 | NVIDIA Jetson Orin Nano Super Developer Kit | +| Brain | Raspberry Pi 5 | AI edge compute device (8 GB model recommended) | 1 | Raspberry Pi 5 (8 GB) | | | 2TB PCIe Gen 4 NVMe M.2 Internal Solid State Hard Drive | 2TB SSD Drive | 1 | Samsung 980 PRO SSD MZ-V8P2T0CW | -| | Pi 3 Click Shield | Pi 3 Click Shield converts the GPIO pins on the NVIDIA Jetson Orin Nano into two mikroBUSβ„’ sockets | 1 | MIKROE-2756 | +| | Pi 3 Click Shield | Converts the Raspberry Pi GPIO pins into two mikroBUSβ„’ sockets for the sensor / RTC click boards | 1 | MIKROE-2756 | | | mikroBUS Shuttle | Mikroe Shuttle is a small add-on board, which is intended to be used with Shuttle click, in order to expand the mikroBUSβ„’ with additional stacking options. | 4 | MIKROE-2882 | | | mikroBUS Shuttle Click | Shuttle click is a mikroBUS socket expansion board, which provides an easy and elegant solution for stacking up to four click boards\\ on a single mikroBUS | 2 | MIKROE-2880 | | | DS3231M I2C Board | Real-time clock module which has an extremely low power consumption, allowing it to be used with a single button cell battery, for an extended period of time | 1 | MIKROE-3770 | @@ -113,8 +103,8 @@ This curated list is designed to simplify the process for beginners and those wi | | | | | | | Build | Outdoor Weatherproof IP65 Electrical Junction Box | Outdoor Weatherproof IP65 Electrical Junction Box, Ventilated Design,Β  Use with Mounting Panel & Hinged Cover. Size: 17"x13"x7" | 1 | ANIMACYN Electrical Junction Box, Ventilated Design, Cable Grommets, Indoor/Outdoor Use with Mounting Panel & Hinged Cover. Waterproof IP 65. (Grey Cover, 17.7"x13.7"x7.9") | | | 18AWG Speaker Wire (Black+Red) | 18AWG Copper Clad Aluminum Speaker Wire (Black + Red) | 1 | GEARit GEARit Speaker Wire 18 Gauge, Speaker Cable 18AWG | -| | Displayport Headless EDID Dongle | Displayport monitor emulator required to guarantee image output during remote control of Jetson unit | 1 | BKFK dp Dummy Plug - Display to hdmi Adapter, Luna Display Virtual Window for Home-edid Emulator-Dummy, displayport Headless dummie dongle(DP-1P) | -| | 5.5mm x 2.5mm 90 Degree Right Angle DC Barrel Male Plug Jack | 5.5mm x 2.5mm 90 Degree Right Angle DC Barrel Plug to Power the Jetson Unit | 1 | Fancasee DC Power Pigtail Cable, 10-Pack 5.5mm x 2.5mm 90 US-CAB-65 | +| | Displayport Headless EDID Dongle | Displayport monitor emulator required to guarantee image output during remote control of the Pi | 1 | BKFK dp Dummy Plug - Display to hdmi Adapter, Luna Display Virtual Window for Home-edid Emulator-Dummy, displayport Headless dummie dongle(DP-1P) | +| | 5.5mm x 2.5mm 90 Degree Right Angle DC Barrel Male Plug Jack | 5.5mm x 2.5mm 90 Degree Right Angle DC Barrel Plug to power the Pi | 1 | Fancasee DC Power Pigtail Cable, 10-Pack 5.5mm x 2.5mm 90 US-CAB-65 | | | 5.5mm x 2.1mm DC Female Plug to Bare Wire | Starlink Mini Power DC Female Plug (you can use either a DC female plug to bare wire, or a fully assembled pig tail) | 1 | Tonton 16AWG DC Power Pigtails Cable - 3.3FT, Pure Copper, Orange - 5.5mm x 2.1mm DC Female Plug to Bare Wire Open End | | | Zip Ties Assorted Size | Zip Ties Assorted Size, Double Sided Toothed,Heavy Duty Cable Wire Ties | 1 | JIANYANG Zip Ties Assorted Size, 8+12+14+18 Inch | | | 10FT USB A to Micro USB Cable | Long Micro USB cable for the Audiomoth | 1 | MOVOYEE Long 10FT USB to Micro USB Cable | @@ -122,7 +112,7 @@ This curated list is designed to simplify the process for beginners and those wi | | | | | | | Network | STARLINK Mini Kit | STARLINK Mini Kit - High-Speed Portable Internet for Remote and Mobile Use | 1 | Starlink Mini Antenna Kit | | | Starlink Ethernet Adapter RJ45 Coupler Waterproof Compatible | Weatherproof Starlink Compatible RJ45 Coupler | 1 | EAZUSE RJ45 Starlink Ethernet Adapter Gen 3/Mini | -| | Cat 6 Outdoor Ethernet Cable from Jetson to Starlink | Cat 6 Outdoor Ethernet Cable, 24AWG 10Gbps Waterproof Direct Burial LLDPE UV Jacket (overall length subject to installation) | 1 | VOIETOLT Cat 6 Outdoor Ethernet Cable 30 ft, 24AWG 10Gbps Cat6 Cable Cord Waterproof Direct Burial LLDPE UV Jacket | +| | Cat 6 Outdoor Ethernet Cable from Pi to Starlink | Cat 6 Outdoor Ethernet Cable, 24AWG 10Gbps Waterproof Direct Burial LLDPE UV Jacket (overall length subject to installation) | 1 | VOIETOLT Cat 6 Outdoor Ethernet Cable 30 ft, 24AWG 10Gbps Cat6 Cable Cord Waterproof Direct Burial LLDPE UV Jacket | | | 2X WiFi Antenna with MHF4/IPEX to RP-SMA Pigtail Antenna WiFi Cable | Pair of 6dBi Dual Band WiFi RP-SMA Male Antenna +2 x 35CM RP-SMA IPEX MHF4 Pigtail Cable for M.2 NGFF WiFi WLAN Card | 1 | HIGHFINE 2 x 6dBi 2.4GHz 5GHz Dual Band WiFi RP-SMA Male Antenna+2 x 35CM RP-SMA IPEX MHF4 Pigtail Cable for M.2 NGFF WiFi WLAN Card | | Optional | 10dBi Long Range Outdoor WiFi Fiberglass Antenna | Optional upgrade to improve overall WiFi Range. Requires additional glands to route the cables through the Junction Box Case, \*\*IMPORTANT NOTE: MHF4/IPEX to RP-SMA Pigtail Antenna WiFi Cable is REQUIRED and must be purchased separately\*\* | 2 | eifagur 10dBi Long Range Dual Band WiFi 2.4GHz 5GHz Fiberglass Antenna | | | | | | | @@ -161,17 +151,17 @@ SPARROW Assembly and Set-Up Guide Β© 2025 by Microsoft is licensed under MIT. --- -## ⚑ 2. One-click Jetson Setup (Recommended) +## ⚑ 2. One-click Raspberry Pi Setup (Recommended) -The repo contains a Jetson configuration script `sparrow_setup.sh` that installs prerequisites, prepares folders, downloads default Triton models, seeds the DS3231 RTC, configures Wi-Fi hotspot, and launches the SPARROW services. +The repo contains a Pi configuration script `sparrow_setup.sh` that installs prerequisites, prepares folders, downloads the default ONNX models, configures the Wi-Fi hotspot, and launches the SPARROW services. To send data to the SPARROW dashboard you will need to pair it with your account. To create an account and obtain an access key visit: -[https://dashboard.sparrow-earth.com/](https://dashboard.sparrow-earth.com/) +[https://www.sparrowstudio.azure.com/](https://www.sparrowstudio.azure.com/) -πŸ“„ [View the SPARROW dashboard Terms & Conditions](https://dev.sparrow-earth.com/agreement) +πŸ“„ [View the SPARROW dashboard Terms & Conditions](https://www.sparrowstudio.azure.com/agreement) -**Script:** Download the SPARROW setup script from this repo once the hardware assembly and Jetson flash is complete (detailed instructions can be found in the build instructions). +**Script:** Download the SPARROW setup script from this repo once the hardware assembly and Pi OS flash is complete (detailed instructions can be found in the build instructions). The setup script should be run from `~/Desktop`. ### ▢️ Usage @@ -201,20 +191,17 @@ Creates `~/Desktop/system` with: β”œβ”€β”€ docker-compose.yml β”œβ”€β”€ sparrow_setup.sh β”œβ”€β”€ Models/ -β”‚ └── tritonserver/ +β”‚ └── tritonserver/ # vestigial dir name; no Triton server runs β”‚ └── model_repository/ β”‚ β”œβ”€β”€ megadetectorv6/ -β”‚ β”‚ β”œβ”€β”€ 1/ -β”‚ β”‚ β”‚ └── model.onnx -β”‚ β”‚ └── config.pbtxt +β”‚ β”‚ └── 1/ +β”‚ β”‚ └── model.onnx β”‚ β”œβ”€β”€ AI4GAmazonClassification/ -β”‚ β”‚ β”œβ”€β”€ 1/ -β”‚ β”‚ β”‚ └── model.onnx -β”‚ β”‚ └── config.pbtxt +β”‚ β”‚ └── 1/ +β”‚ β”‚ └── model.onnx β”‚ └── megadetector_birds_v1/ -β”‚ β”œβ”€β”€ 1/ -β”‚ β”‚ └── model.onnx -β”‚ └── config.pbtxt +β”‚ └── 1/ +β”‚ └── model.onnx β”œβ”€β”€ sparrow/ β”‚ β”œβ”€β”€ Dockerfile β”‚ β”œβ”€β”€ config/ @@ -233,8 +220,22 @@ Creates `~/Desktop/system` with: ``` -### 4️⃣ Models + Configs -Downloads three default ONNX models from Zenodo and writes minimal `config.pbtxt` for each Triton repo. +### 4️⃣ Models +Downloads three default ONNX models from Zenodo into `Models/tritonserver/model_repository//1/model.onnx`. The `tritonserver/` directory name is vestigial β€” inference is ONNXRuntime-only β€” but the path layout is what `LOCAL_MODELS_DIR` and `inference.py` expect. + +### πŸ”§ XBee Radio Config (ROBIN deployments only) +If you answer **yes** to "Is ROBIN being used?", the script auto-detects the FTDI XBee adapter on `/dev/serial/by-id/` and runs `xbee_configure.py` once over AT command mode, writing these settings to the radio's flash (persistent across reboots): + +| AT Register | Value | Purpose | +|---|---|---| +| `AP` | `1` | API mode (no escapes) β€” required for the framed protocol `xbee_master_collect.py` uses at runtime | +| `CE` | `0` | Standard router β€” routes packets in the mesh | +| `BR` | `1` | RF data rate 80 kbps β€” must match remote nodes | +| `ID` | `1234` | Network ID β€” must match remote nodes (robins, camera nodes) | +| `NI` | `SPARROW_MASTER` | Node identifier | +| `BD` | `7` | UART baud 115200 | + +The radio is auto-probed across common bauds (115200, 9600, 57600, 19200, 38400, 230400) so it works whether the adapter shipped pre-configured or virgin. Settings are written with `WR`, so re-running setup is safe but unnecessary once the XBee has been provisioned. ### 5️⃣ Access Key Prompts for the server access key (obtained from the SPARROW dashboard) and writes it to: @@ -243,15 +244,12 @@ sparrow/config/access_key.txt starlink/config/access_key.txt ``` -### 6️⃣ RTC Seeding (DS3231 over I2C bus 7) -Gets UTC from WorldClock API (fallback: NTP or system UTC) and writes it to the RTC. - -### 7️⃣ Wi-Fi Hotspot +### 6️⃣ Wi-Fi Hotspot Configures a persistent hotspot via NetworkManager: **SSID:** `CameraTraps` **Password:** `User prompted` -### 8️⃣ Docker Build & Launch +### 7️⃣ Docker Build & Launch Builds images with BuildKit (no cache), runs `docker-compose up -d`, and tails logs. --- @@ -260,8 +258,8 @@ Builds images with BuildKit (no cache), runs `docker-compose up -d`, and tails l All Python dependencies are inside the containers (no host Python required): -1. `PyTorch`, `torchaudio`, `tritonclient`, `aiosmtpd`, `psutil`, `smbus2`, `pyserial`, etc. -2. **NVIDIA Triton Inference Server** (explicit model control mode) +1. `PyTorch`, `torchaudio`, `aiosmtpd`, `psutil`, `smbus2`, `pyserial`, `Pillow`, `piexif`, etc. +2. **ONNXRuntime** β€” inference runs directly via `onnxruntime.InferenceSession`; no separate inference server. 3. **Hardware:** I2C, ALSA audio, and USB serial (compose is configured privileged) --- @@ -272,15 +270,77 @@ The client calls these endpoints on `SERVER_BASE_URL`: | Endpoint | Description | |-----------|--------------| -| `/uploads` | Image + detection metadata | +| `/uploads/image` | Image + detections JSON (one POST per image, includes pixel-int bbox per detection) | | `/audio_uploads` | WAV audio files | | `/system_metrics` | System + sensor metrics JSON | | `/get_schedule` | Starlink sleep window | | `/get_scheduleaudio` | Audio capture settings | | `/model_settings` | Classification model + labels | -| `/model_update` | Triton model manifest | +| `/model_update` | Model manifest used by `model_update.py` to mirror the on-disk model repo | + +Each request includes `auth_key` (or the `X-API-Key` header) and a `unique_id` (or `X-Unit-ID` header) derived from `/etc/unique_id`. + +--- + +# πŸ”„ Automatic Updates + +Once a SPARROW Pi is in the field, code changes are picked up automatically. A small bash daemon (`updater/sparrow-update.sh`, deployed by `updater/install.sh` to `/usr/local/sbin/`) polls this repo every 15 minutes, deploys the newest matching tag, rebuilds the docker-compose stack, and **rolls back automatically** if the new build doesn't pass a 60-second post-restart health check. + +## What gets deployed + +The updater only acts on tags matching: + +``` +^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}(-[A-Za-z0-9._-]+)?$ +``` + +So `v2026.06.10` or `v2026.06.10-hotfix1` ship; plain commits on `main`, branch names, or `release/...` tags don't. Tagging is the gate β€” pushing to `main` without a tag is a no-op for every deployed Pi. + +## Shipping a change + +```bash +# from your dev machine +git commit -am "fix the thing" +git push +git tag v2026.06.10 +git push origin v2026.06.10 +``` + +Within 15 minutes every Pi running the updater picks it up. Force an immediate run via SSH if you want it sooner: + +```bash +ssh sparrow@ 'sudo systemctl start sparrow-update.service' +``` + +## What's preserved across updates + +The updater's rsync explicitly excludes everything Pi-specific so deployments never clobber local state: + +- `sparrow.env`, `starlink.env` β€” deployment-specific server URLs / passwords +- `sparrow/config/access_key.txt`, `starlink/config/access_key.txt` +- `sparrow/{logs,images,recordings,static}/`, `starlink/{logs,config}/` +- `Models/` β€” ONNX files are managed by `model_update.py` from the server, not by git +- `.git/` β€” so `git` on the Pi stays usable + +If you need to change one of these on a deployed Pi, edit it on the Pi (`sudo nano /home/sparrow/Desktop/system/sparrow.env`); don't push it through a tag. + +## Failure paths + +- **Tarball download or compose-validate fails** β†’ exit cleanly, try again on the next 15-min tick. +- **`docker-compose build` fails** β†’ restore the previous code from the on-disk backup, rebuild against it, log `ROLLBACK`. +- **Health check fails after restart** β†’ same: restore backup, rebuild, recreate. The previous working containers come back. +- **Both new deploy AND rollback fail health check** β†’ a `STUCK` flag is dropped at `/var/lib/sparrow-update/STUCK`. The timer keeps firing but the script bails immediately while the flag is present so it can't make things worse. Clear by hand after diagnosis: `sudo rm /var/lib/sparrow-update/STUCK`. + +## Bootstrap on a fresh Pi + +The setup wizard (`sparrow_setup.sh`) ends with a prompt β€” answer **yes** to install the updater. Or install it later by hand: + +```bash +cd /home/sparrow/Desktop/system +sudo bash updater/install.sh +``` -Each request includes `auth_key` and a `unique_id` derived from `/etc/unique_id`. +Full operator docs (status commands, the `STUCK` flag, log rotation, config overrides) are in [`updater/README.md`](updater/README.md). --- diff --git a/docker-compose.yml b/docker-compose.yml index 4dd862d..a6857e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: sparrow: @@ -7,9 +7,7 @@ services: container_name: sparrow_service env_file: - ./sparrow.env - ports: - - "25:25" # SMTP server port - - "21:21" # FTP server port + network_mode: host volumes: - ./sparrow/config:/app/config - ./sparrow/images:/app/images @@ -19,9 +17,10 @@ services: - /sys:/host/sys:ro - /proc:/host/proc:ro - /etc/unique_id:/host/etc/unique_id:ro + - /dev/serial:/dev/serial:ro - ./Models/tritonserver/model_repository:/home/sparrow/Desktop/system/Models/tritonserver/model_repository - networks: - - sparrow_network + devices: + - /dev/serial/by-id/usb-FTDI_FT231X_USB_UART_DU0D4EKZ-if00-port0:/dev/xbee_serial privileged: true restart: unless-stopped @@ -32,34 +31,18 @@ services: container_name: starlink_tools_container env_file: - ./starlink.env + networks: + - sparrow_network volumes: - ./starlink/config:/app/config - ./starlink/logs:/app/logs - /sys:/host/sys:ro - /proc:/host/proc:ro - /etc/unique_id:/host/etc/unique_id:ro - networks: - - sparrow_network + - /dev/serial:/dev/serial:ro privileged: true restart: unless-stopped - triton: - image: nvcr.io/nvidia/tritonserver:24.06-py3-igpu - container_name: triton_server - runtime: nvidia - environment: - - OMP_NUM_THREADS=2 - ports: - - "8000:8000" # HTTP Inference API - - "8001:8001" # gRPC Inference API - - "8002:8002" # Metrics API - volumes: - - ./Models/tritonserver/model_repository:/models - command: tritonserver --model-repository=/models --model-control-mode=explicit --strict-readiness=false - networks: - - sparrow_network - restart: unless-stopped - networks: sparrow_network: driver: bridge diff --git a/docs-requirements.txt b/docs-requirements.txt deleted file mode 100644 index fac328b..0000000 --- a/docs-requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -mkdocs>=1.6 -mkdocs-material>=9.7 -mkdocs-material-extensions>=1.3 -pymdown-extensions>=10.0 -mkdocstrings>=0.27 -mkdocstrings-python>=1.0 -mkdocs-callouts -mkdocs-git-revision-date-localized-plugin diff --git a/docs/assets/cat.png b/docs/assets/cat.png deleted file mode 100644 index 696b06d..0000000 Binary files a/docs/assets/cat.png and /dev/null differ diff --git a/docs/build_mkdocs.md b/docs/build_mkdocs.md deleted file mode 100644 index 9313992..0000000 --- a/docs/build_mkdocs.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -description: "How to build and deploy the SPARROW MkDocs documentation site locally and to GitHub Pages." -tags: - - SPARROW - - documentation - - MkDocs - - developer-guide ---- - -# Developer Guide β€” Building the Docs - -This page explains how to build and preview the SPARROW documentation site locally. - -## Prerequisites - -Install the documentation dependencies (separate from the ML/hardware requirements): - -```bash -pip install -r docs-requirements.txt -``` - -## Local Preview - -Start the live-reload dev server: - -```bash -mkdocs serve -``` - -Open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. The site rebuilds automatically when you save any file under `docs/` or `mkdocs.yml`. - -## Build (Static) - -To build the static site without serving it: - -```bash -mkdocs build -``` - -Output goes to `site/` (gitignored β€” never commit this directory). - -## Deploy to GitHub Pages - -Deployment happens automatically via GitHub Actions on every push to `main` that touches `docs/**`, `mkdocs.yml`, or `docs-requirements.txt`. The workflow in `.github/workflows/deploy-docs.yml` builds the site and force-pushes to the `gh-pages` branch. - -To deploy manually: - -```bash -mkdocs gh-deploy --force -``` - -## Adding a New Page - -1. Create `docs/.md` with the required SEO front matter: - -```yaml ---- -description: "One-sentence description for search engines and the site description meta tag." -tags: - - SPARROW - - relevant-tag ---- -``` - -2. Add the page to the `nav:` section of `mkdocs.yml`. diff --git a/docs/cite.md b/docs/cite.md deleted file mode 100644 index 409504f..0000000 --- a/docs/cite.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -description: "How to cite SPARROW β€” software citation and BibTeX entry for the SPARROW edge AI wildlife monitoring system by Microsoft AI for Good Lab." -tags: - - SPARROW - - citation - - research ---- - -# Cite Us - -If you use SPARROW in your research or deployments, please cite the software using the metadata below. - -## Software Citation - -```bibtex -@software{sparrow2026, - author = {Lavista Ferres, Juan M. and Chalmers, Carl and Demuro Segundo, Bruno and - Miao, Zhongqi and Hernandez Celis, Andres and Chacon Silva, Isai Daniel and - Kim, Allen and Marotti, Luana and Michaels, Amy and Ruiz Lopez, Daniela and - Dodhia, Rahul and Becker-Reshef, Inbal and Arbelaez Escalante, Pablo AndrΓ©s and - Alves Torres, Federico and Machado, Meygha and Cintron Roman, Anthony}, - title = {{SPARROW}: Solar-Powered Acoustic and Remote Recording Observation Watch}, - year = {2026}, - publisher = {Microsoft AI for Good Lab}, - url = {https://github.com/microsoft/SPARROW} -} -``` - -## Technical Paper - -We have published a technical paper on SPARROW. A preprint is available at [aka.ms/sparrowpaper](https://aka.ms/sparrowpaper). - -## CFF Citation - -A machine-readable `citation.cff` file is included in the [SPARROW repository](https://github.com/microsoft/SPARROW/blob/main/citation.cff) and is recognized automatically by GitHub's "Cite this repository" feature. diff --git a/docs/hardware.md b/docs/hardware.md deleted file mode 100644 index 65bf0c7..0000000 --- a/docs/hardware.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -description: "SPARROW hardware assembly guide β€” bill of materials for NVIDIA Jetson Orin Nano, Starlink satellite, solar power, AudioMoth acoustic sensor, and Reolink camera trap edge AI system." -tags: - - SPARROW - - hardware - - jetson-orin-nano - - bill-of-materials - - starlink - - solar-powered - - audiomoth - - camera-trap - - assembly ---- - -# Hardware Setup - -SPARROW is a solar-powered edge AI computing unit built around the **NVIDIA Jetson Orin Nano**. It collects data from camera traps, acoustic sensors, and environmental monitors, processes it on-device using PyTorch-Wildlife models via the NVIDIA Triton Inference Server, and transmits results via Starlink satellite. - -This page summarizes the hardware components needed to build a SPARROW unit. For full step-by-step assembly instructions, wiring diagrams, and part numbers, download the official guides: - -- πŸ“‹ **[SPARROW Bill of Materials](https://aka.ms/sparrowbom)** β€” full component list with recommended vendors and quantities -- πŸ—οΈ **[SPARROW Assembly and Setup Guide](https://aka.ms/sparrowassembly)** β€” step-by-step wiring and assembly instructions - ---- - -## Hardware Components by Category - -### Brain β€” Compute - -| Component | Role | -|---|---| -| NVIDIA Jetson Orin Nano Super Developer Kit | Primary edge compute unit β€” runs AI models and all SPARROW services | -| 2TB PCIe Gen 4 NVMe SSD | Local image/audio storage for offline operation | -| DS3231M RTC (IΒ²C) | Real-time clock for accurate timestamps in remote deployments | -| BME688 Environmental Sensor (IΒ²C) | Temperature, humidity, pressure, and gas sensing | -| SHTC3 Temperature/Humidity Sensor (IΒ²C) | Redundant environmental measurement | -| IΒ²C Relay Board | Controlled power switching for peripheral components | -| Pi 3 Click Shield + mikroBUS Shuttles | Adapter to connect Mikroe Click boards to Jetson GPIO | - -!!! note "IΒ²C Board Compatibility" - The assembly guide is developed around **Mikroe MikroBUS** click boards. Generic IΒ²C boards may work but require custom address mapping and are recommended only for advanced users. - ---- - -### Power β€” Solar + Battery - -| Component | Role | -|---|---| -| MPPT Solar Charge Controller (24V/15A+) | Maximizes solar panel harvest; recommended: Victron SmartSolar MPPT 100/20 | -| 100W Monocrystalline Solar Panels (Γ—2) | Primary power source β€” wired in 24V series configuration | -| 24V 50–100Ah LiFePO4 Battery | Energy storage for overnight and cloudy-day operation | -| Solar Panel Mount Brackets (45") | Tiltable mounting for optimal sun angle | -| 10AWG Weatherproof Cabling | Solar panel β†’ charge controller β†’ battery wiring | - ---- - -### Network β€” Satellite Connectivity - -| Component | Role | -|---|---| -| Starlink Mini Kit | Low-Earth-orbit satellite internet for remote data uplink | -| Starlink RJ45 Weatherproof Ethernet Adapter | Weatherproof wired connection from Jetson to Starlink dish | -| Cat 6 Outdoor Ethernet Cable | Direct-burial UV-rated cable between Jetson and Starlink | -| Dual-band WiFi Antenna (MHF4/IPEX to RP-SMA) | Internal WiFi for camera trap connectivity | -| 10dBi Long-Range Outdoor WiFi Antenna (optional) | Extended WiFi range for large camera trap deployments (up to 150 cameras) | - ---- - -### Audio β€” Acoustic Monitoring - -| Component | Role | -|---|---| -| AudioMoth Dev Board + Case | Open-source acoustic sensor for bioacoustic monitoring (bird calls, bat echolocation, etc.) | - ---- - -### Camera - -| Component | Role | -|---|---| -| Solar WiFi Camera Trap (Γ—1+) | Camera traps for wildlife image capture β€” SPARROW supports **up to 150 cameras** per unit; recommended: Reolink Argus Eco+ | - ---- - -### Enclosure - -| Component | Role | -|---|---| -| IP65 Weatherproof Junction Box (17"Γ—13"Γ—7") | Houses all electronics; rated for outdoor/field deployment | -| Weatherproof Cable Gland Set (PG7–PG19) | Sealed cable routing in/out of enclosure | -| 18AWG Speaker Wire, Zip Ties, DC Barrel Plugs | Internal wiring and cable management | - ---- - -## Assembly Process Overview - -1. **Enclosure preparation** β€” drill gland holes, mount DIN rail or panel -2. **Power wiring** β€” solar panels β†’ charge controller β†’ battery β†’ Jetson -3. **Sensor installation** β€” connect IΒ²C boards (RTC, BME688, SHTC3, relay) to Jetson GPIO via Click Shield -4. **Camera network** β€” mount outdoor WiFi antenna(s); configure camera trap SSIDs -5. **Starlink setup** β€” weatherproof cable routing and dish alignment -6. **AudioMoth** β€” connect via USB; configure recording schedule -7. **Final check** β€” verify all connections before first power-up - -For detailed instructions with photos and wiring diagrams, see the [SPARROW Assembly and Setup Guide](https://aka.ms/sparrowassembly). - -Once hardware is assembled, proceed to [Software Setup](setup.md). diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 6fb1bd2..0000000 --- a/docs/index.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -description: "SPARROW β€” Solar-Powered Acoustic and Remote Recording Observation Watch. Microsoft AI for Good Lab's open-source edge AI system for wildlife monitoring with NVIDIA Jetson, Starlink, and PyTorch-Wildlife." -tags: - - SPARROW - - edge-ai - - wildlife-monitoring - - conservation - - camera-traps - - jetson-orin-nano - - starlink - - pytorch-wildlife - - bioacoustics - - solar-powered ---- - -# SPARROW - -**SPARROW** (Solar-Powered Acoustic and Remote Recording Observation Watch) is Microsoft AI for Good Lab's open-source edge AI system for wildlife monitoring in the most remote regions of the world. - -Solar-powered and equipped with multi-modal sensors, SPARROW collects biodiversity data from camera traps, acoustic monitors, and environmental sensors. It processes that data **on-device** using [PyTorch-Wildlife](https://github.com/microsoft/Pytorch-Wildlife) models running on the NVIDIA Jetson Orin Nano β€” no cloud required for inference β€” then transmits results via Starlink low-Earth-orbit satellites for near-real-time insights anywhere on the planet. - ---- - -## What SPARROW Does - -A single SPARROW unit autonomously: - -1. **Collects data** from up to 150 solar WiFi camera traps, an AudioMoth acoustic sensor, and IΒ²C environmental sensors (temperature, humidity, pressure) -2. **Runs AI models on-device** via NVIDIA Triton Inference Server β€” MegaDetector v6 for animal/human/vehicle detection, species classifiers for identification, and bird-specific detectors -3. **Manages power** intelligently using MPPT solar charging, LiFePO4 batteries, and dynamic component scheduling for continuous off-grid operation -4. **Transmits data** via Starlink satellite when connectivity is available; stores data locally when offline and syncs automatically on reconnect -5. **Scrubs privacy data** β€” automatically detects and removes human-related images before upload - -All services run in Docker containers on the Jetson, orchestrated by Docker Compose. - ---- - -## Key Capabilities - -| Capability | Details | -|---|---| -| **On-device AI inference** | MegaDetector v6, Amazon species classifier, bird detector β€” all as ONNX models on NVIDIA Triton | -| **Camera trap support** | Up to 150 solar WiFi cameras per unit (e.g., Reolink Argus Eco+) | -| **Acoustic monitoring** | AudioMoth integration for bioacoustics (birds, bats, insects) | -| **Global connectivity** | Starlink Mini satellite uplink β€” works in locations with no cellular or WiFi | -| **Solar autonomous** | 24V LiFePO4 battery + MPPT solar controller; designed for months of unattended deployment | -| **Environmental sensing** | Temperature, humidity, pressure, gas (BME688 + SHTC3 IΒ²C sensors) | -| **Privacy by design** | On-device human image scrubbing before any data leaves the device | - ---- - -## Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ SPARROW Unit (Weatherproof IP65 Box) β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ NVIDIA Jetson Orin Nano β”‚ β”‚ -β”‚ β”‚ β”œβ”€β”€ sparrow (Docker) β”‚ β”‚ -β”‚ β”‚ β”‚ β”œβ”€β”€ Camera polling/ingest β”‚ β”‚ -β”‚ β”‚ β”‚ β”œβ”€β”€ Triton inference β”‚ β”‚ -β”‚ β”‚ β”‚ β”œβ”€β”€ Power mgmt (solar) β”‚ β”‚ -β”‚ β”‚ β”‚ └── Data sync + FTP upload β”‚ β”‚ -β”‚ β”‚ └── starlink (Docker) β”‚ β”‚ -β”‚ β”‚ └── Satellite link monitor β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ Power: Solar panels β†’ MPPT β†’ LiFePO4 β”‚ -β”‚ Network: Starlink Mini satellite dish β”‚ -β”‚ Sensors: AudioMoth, IΒ²C env sensors β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ Data upload (Starlink) - β–Ό - SPARROW Dashboard (cloud) - sparrow-earth.com -``` - ---- - -## Getting Started - -1. **[Hardware Setup](hardware.md)** β€” Bill of materials, component overview, and assembly guide -2. **[Software Setup](setup.md)** β€” One-click Jetson setup script, Docker architecture, AI models -3. **[Cite Us](cite.md)** β€” How to cite SPARROW in publications -4. **[GitHub Repository](https://github.com/microsoft/SPARROW)** β€” Source code, issues, and contributions - ---- - -## Part of the Microsoft Biodiversity Ecosystem - -| Repository | Description | -|---|---| -| [microsoft/Biodiversity](https://github.com/microsoft/Biodiversity) | Umbrella hub β€” PyTorch-Wildlife, MegaDetector, ecosystem overview | -| [microsoft/MegaDetector](https://github.com/microsoft/MegaDetector) | Animal/human/vehicle detector for camera traps | -| [microsoft/Pytorch-Wildlife](https://github.com/microsoft/Pytorch-Wildlife) | Unified AI framework: detection + species classification | -| [microsoft/MegaDetector-Acoustic](https://github.com/microsoft/MegaDetector-Acoustic) | Bioacoustic AI for audio-based wildlife detection and classification | -| [microsoft/MegaDetector-Overhead](https://github.com/microsoft/MegaDetector-Overhead) | Wildlife detection in aerial and drone imagery | -| [microsoft/MegaDetector-Sonar](https://github.com/microsoft/MegaDetector-Sonar) | Sonar-based wildlife detection for aquatic monitoring | -| [microsoft/MegaDetector-Classifier](https://github.com/microsoft/MegaDetector-Classifier) | Camera-trap species classification fine-tuning β€” adapt classifiers to your own datasets and geographic regions | -| [microsoft/SPARROW](https://github.com/microsoft/SPARROW) | This repo β€” solar-powered edge AI for wildlife monitoring | -| SPARROW Studio | Desktop application for all AI for Good Lab models | diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index 8c465fa..0000000 --- a/docs/setup.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -description: "SPARROW software setup β€” one-click Jetson setup script, Docker Compose architecture, NVIDIA Triton inference server, MegaDetector ONNX models, and SPARROW dashboard configuration." -tags: - - SPARROW - - software-setup - - docker - - jetson - - triton-inference-server - - megadetector - - onnx - - installation ---- - -# Software Setup - -SPARROW runs entirely in Docker containers orchestrated by Docker Compose. This page covers how to configure and launch the SPARROW software stack on a Jetson Orin Nano. - ---- - -## Prerequisites - -- Jetson Orin Nano with **JetPack 6.x** installed and flashed -- Hardware assembled per the [Hardware Setup](hardware.md) guide -- SPARROW dashboard account and access key β€” register at [dashboard.sparrow-earth.com](https://dashboard.sparrow-earth.com/) - ---- - -## One-Click Setup (Recommended) - -The `sparrow_setup.sh` script in the `setup script/` directory automates the entire Jetson configuration in a single command. - -**Run from `~/Desktop`:** - -```bash -cd ~/Desktop -sudo chmod +x sparrow_setup.sh -sudo ./sparrow_setup.sh -``` - -### What the script does - -| Step | Action | -|---|---| -| **1. Prerequisites** | Installs `docker`, `docker-compose`, `git`, `curl`, `wget`, `uuidgen`, `smbus2` | -| **2. Device identity** | Generates `/etc/unique_id` (UUID) if missing | -| **3. Folder layout** | Creates `~/Desktop/system/` with all required directories (see below) | -| **4. Models** | Downloads three default ONNX models from Zenodo; writes Triton `config.pbtxt` | -| **5. Access key** | Prompts for your SPARROW dashboard access key; writes to `sparrow/config/access_key.txt` and `starlink/config/access_key.txt` | -| **6. RTC seeding** | Gets UTC from WorldClock API (fallback: NTP) and writes to DS3231 RTC over IΒ²C bus 7 | -| **7. Wi-Fi hotspot** | Configures a persistent `NetworkManager` hotspot on the Jetson for camera trap connectivity | -| **8. Docker build + launch** | Builds images with BuildKit (no cache), runs `docker-compose up -d`, tails logs | - -### Deployed folder structure - -``` -~/Desktop/system/ -β”œβ”€β”€ docker-compose.yml -β”œβ”€β”€ sparrow_setup.sh -β”œβ”€β”€ Models/ -β”‚ └── tritonserver/ -β”‚ └── model_repository/ -β”‚ β”œβ”€β”€ megadetectorv6/ ← MegaDetector v6 ONNX -β”‚ β”‚ β”œβ”€β”€ 1/model.onnx -β”‚ β”‚ └── config.pbtxt -β”‚ β”œβ”€β”€ AI4GAmazonClassification/ ← Amazon species classifier ONNX -β”‚ β”‚ β”œβ”€β”€ 1/model.onnx -β”‚ β”‚ └── config.pbtxt -β”‚ └── megadetector_birds_v1/ ← Bird detector ONNX -β”‚ β”œβ”€β”€ 1/model.onnx -β”‚ └── config.pbtxt -β”œβ”€β”€ sparrow/ -β”‚ β”œβ”€β”€ Dockerfile -β”‚ β”œβ”€β”€ config/access_key.txt -β”‚ β”œβ”€β”€ images/ -β”‚ β”œβ”€β”€ recordings/ -β”‚ β”œβ”€β”€ logs/ -β”‚ └── static/data/ static/gallery/ -└── starlink/ - β”œβ”€β”€ Dockerfile.starlink - β”œβ”€β”€ config/access_key.txt - └── logs/ -``` - ---- - -## AI Models - -SPARROW uses NVIDIA Triton Inference Server to run ONNX models on the Jetson's GPU. Three models are deployed by default: - -| Model | Purpose | -|---|---| -| `megadetectorv6` | Detects animals, people, and vehicles in camera trap images ([MegaDetector](https://github.com/microsoft/MegaDetector)) | -| `AI4GAmazonClassification` | Species classification for Amazon Basin wildlife | -| `megadetector_birds_v1` | Bird-specific detector optimized for acoustic + visual monitoring | - -All models are downloaded as ONNX format and served via Triton's gRPC/HTTP endpoints on ports 8000–8002. - ---- - -## Docker Services - -Two containers run on the Jetson: - -### `sparrow` - -The main SPARROW service β€” handles: -- **Camera trap management** β€” WiFi camera polling, image download, deduplication -- **On-device inference** β€” submits images to Triton for MegaDetector detection + species classification -- **Power management** β€” monitors solar charge controller, battery state, dynamic component scheduling -- **Telemetry** β€” environmental sensor readings (BME688, SHTC3), system health metrics -- **Data sync** β€” FTP/HTTP upload to SPARROW dashboard when connectivity is available -- **Privacy scrubbing** β€” removes human-related images before upload - -### `starlink` - -Monitors Starlink satellite connectivity via gRPC, logs signal metrics, and triggers data sync when uplink is available. - ---- - -## Environment Configuration - -The `sparrow.env` and `starlink.env` files in the repo root are **configuration templates**. The setup script fills in deployment-specific values (access key, FTP password). Key variables: - -| Variable | Purpose | -|---|---| -| `SERVER_BASE_URL` | SPARROW dashboard API endpoint | -| `FTP_PASS` | FTP upload credential (prompted during setup) | -| `UNIQUE_ID_PATH` | Path to device UUID (`/host/etc/unique_id`) | - -!!! warning - Never commit `.env` files containing real credentials. The `.gitignore` already excludes all `.env` files. - ---- - -## Manual Launch (Advanced) - -To launch without the setup script (assumes folder structure already exists): - -```bash -cd ~/Desktop/system -docker compose up -d -``` - -View logs: - -```bash -docker compose logs -f -``` - -Restart a specific service: - -```bash -docker compose restart sparrow -``` - ---- - -## SPARROW Dashboard - -Data collected and processed by SPARROW is uploaded to the [SPARROW Dashboard](https://dashboard.sparrow-earth.com/) for visualization, filtering, and export. - -- Register for an account and obtain your access key at [dashboard.sparrow-earth.com](https://dashboard.sparrow-earth.com/) -- Dashboard Terms & Conditions: [sparrow-earth.com/agreement](https://dev.sparrow-earth.com/agreement) diff --git a/docs/tags.md b/docs/tags.md deleted file mode 100644 index 663e933..0000000 --- a/docs/tags.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -description: "Tag index for the SPARROW documentation site." -tags: [] ---- - - diff --git a/documentation/SPARROW_Assembly_and_Setup_Guide.pdf b/documentation/SPARROW_Assembly_and_Setup_Guide.pdf index 161d299..72cb0db 100644 Binary files a/documentation/SPARROW_Assembly_and_Setup_Guide.pdf and b/documentation/SPARROW_Assembly_and_Setup_Guide.pdf differ diff --git a/documentation/SPARROW_Bill_of_Materials_-_BOM.pdf b/documentation/SPARROW_Bill_of_Materials_-_BOM.pdf index 4d95049..aeaac28 100644 Binary files a/documentation/SPARROW_Bill_of_Materials_-_BOM.pdf and b/documentation/SPARROW_Bill_of_Materials_-_BOM.pdf differ diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index f1ba828..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,110 +0,0 @@ -site_name: SPARROW -site_url: https://microsoft.github.io/SPARROW/ -site_description: "SPARROW β€” Solar-Powered Acoustic and Remote Recording Observation Watch. AI-powered edge computing for wildlife monitoring by Microsoft AI for Good Lab." -docs_dir: docs -site_dir: site -repo_url: https://github.com/microsoft/SPARROW -repo_name: microsoft/SPARROW -copyright: Copyright (c) 2023 Microsoft Corporation - -theme: - name: material - custom_dir: overrides - favicon: assets/cat.png - logo: assets/cat.png - icon: - menu: material/menu - alternate: material/translate - search: material/magnify - share: material/share-variant - close: material/close - top: material/arrow-up - edit: material/pencil - view: material/eye - repo: fontawesome/brands/git-alt - admonition: - note: material/note - abstract: material/lightbulb - info: material/information - tip: material/lightbulb-on - success: material/check-circle - question: material/help-circle - warning: material/alert - failure: material/alert-circle - danger: material/alert-octagon - bug: material/bug - example: material/format-list-bulleted - quote: material/format-quote-open - tag: - default: material/tag - info: material/information - warning: material/alert - danger: material/alert-octagon - previous: material/arrow-left - next: material/arrow-right - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: green - accent: deep orange - toggle: - icon: material/paw-off - name: Switch to dark mode - - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: teal - accent: deep orange - toggle: - icon: material/paw - name: Switch to light mode - features: - - navigation.tracking - - navigation.tabs - - navigation.sections - - navigation.path - - toc.follow - - navigation.top - - search.suggest - - search.share - - navigation.footer - -nav: - - SPARROW: - - Overview: index.md - - Hardware Setup: hardware.md - - Software Setup: setup.md - - Tags: tags.md - - Cite Us: cite.md - - Developer Guide: build_mkdocs.md - -markdown_extensions: - - admonition - - pymdownx.details - - pymdownx.superfences - - md_in_html - - attr_list - - sane_lists - - pymdownx.tabbed: - alternate_style: true - - toc: - permalink: true - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - pymdownx.snippets: - check_paths: true - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - -plugins: - - callouts - - search - - meta - - tags - - git-revision-date-localized: - type: date - enable_creation_date: true - fallback_to_build_date: true diff --git a/overrides/main.html b/overrides/main.html deleted file mode 100644 index aafe007..0000000 --- a/overrides/main.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends "base.html" %} - -{# - Theme override for SEO structured data and a clean homepage . - Schema is gated on page.file.src_path (unique per page). Homepage uses a - connected @graph (WebSite + SoftwareSourceCode + Organization) per schema review. -#} - -{% block htmltitle %} - {%- if page and page.file and page.file.src_path == "index.md" -%} - <title>SPARROW: Solar-Powered Edge AI for Biodiversity Monitoring | Microsoft AI for Good - {%- else -%} - {{ super() }} - {%- endif -%} -{% endblock %} - -{% block extrahead %} - {{ super() }} - - {%- set social_title = page.meta.title if page and page.meta and page.meta.title else (page.title if page and page.title else config.site_name) -%} - {%- set social_desc = page.meta.description if page and page.meta and page.meta.description else config.site_description -%} - - - - - - - - - - - - - {#- TODO(design): swap in a 1200x630 banner + add og:image:width/height when delivered -#} - - {%- if page and page.file and page.file.src_path == "index.md" %} - - {%- endif %} - - {%- if page and page.file and page.file.src_path != "index.md" and page.canonical_url %} - - {%- endif %} -{% endblock %} diff --git a/setup script/sparrow_setup.sh b/setup script/sparrow_setup.sh index c6d9354..2cff66a 100644 --- a/setup script/sparrow_setup.sh +++ b/setup script/sparrow_setup.sh @@ -1,8 +1,15 @@ #!/usr/bin/env bash ############################################################################### -# SPARROW Setup Script 27-January-2026 +# SPARROW Setup Script (Raspberry Pi 5 / Debian Trixie) +# - Uses Docker's official Debian repo for Docker Engine + Compose plugin +# - Enables I2C (+ optional DS3231 overlay) via boot config +# - Installs Witty Pi 5 software and configures auto power-on on power restore +# - Clones a private GitHub repo using a prompted fine-grained PAT +# - Keeps model/version folder structure, without Triton config.pbtxt files +# - Shows command output in terminal and logs to /var/log/sparrow_setup.log ############################################################################### set -euo pipefail +export GTK_A11Y=none ############################################################################### # 0. -- GUI HELPERS -- @@ -18,24 +25,28 @@ _install_zenity() { fi } -_yesno() { # returns 0 (true) for Yes / OK +_yesno() { if $GUI && command -v zenity >/dev/null 2>&1; then zenity --question --no-wrap --text="$1" --width=440 else - read -rp "$1 (y/n): " _ans; [[ "${_ans,,}" =~ ^y(es)?$ ]] + read -rp "$1 (y/n): " _ans + [[ "${_ans,,}" =~ ^y(es)?$ ]] fi } -_input() { # $1 prompt , $2 = "hide" to mask +_input() { if $GUI && command -v zenity >/dev/null 2>&1; then local opts=(--entry --text="$1" --width=460) [[ "${2:-}" == "hide" ]] && opts+=(--hide-text) zenity "${opts[@]}" else if [[ "${2:-}" == "hide" ]]; then - read -rsp "$1: " _txt; echo; echo "$_txt" + read -rsp "$1: " _txt + echo + echo "$_txt" else - read -rp "$1: " _txt; echo "$_txt" + read -rp "$1: " _txt + echo "$_txt" fi fi } @@ -43,28 +54,28 @@ _input() { # $1 prompt , $2 = "hide" to mask _info() { $GUI && command -v zenity >/dev/null 2>&1 && zenity --info --no-wrap --text="$*" --width=480 || echo -e "$*"; } _error() { $GUI && command -v zenity >/dev/null 2>&1 && zenity --error --no-wrap --text="$*" --width=480 || { echo -e "ERROR: $*" >&2; } } -_progress() { # $1 = message, $2… = command +_progress() { + local msg="$1" + shift + if $GUI && command -v zenity >/dev/null 2>&1; then - ( "${@:2}" ) 2>&1 | zenity --progress --pulsate --no-cancel \ - --auto-close --text="$1" --width=480 + ( + echo "$msg" + "$@" 2>&1 | tee /dev/tty + ) | zenity --progress --pulsate --no-cancel --auto-close --text="$msg" --width=480 else - "${@:2}" + "$@" fi } ############################################################################### # 1. -- CONFIGURATION -- ############################################################################### -DOCKER_COMPOSE_VERSION="2.21.0" -DOCKER_COMPOSE_URL="https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -DOCKER_COMPOSE_PATH="/usr/local/bin/docker-compose" -SYMLINK_PATH="/usr/bin/docker-compose" - LOG_FILE="/var/log/sparrow_setup.log" UUID_FILE="/etc/unique_id" HOTSPOT_SSID="CameraTraps" -HOTSPOT_PASSWORD="" # set interactively via prompt_hotspot_password +HOTSPOT_PASSWORD="" WIFI_INTERFACE="wlan0" MODEL_DOWNLOAD_URL="https://zenodo.org/record/14661733/files/MDV6b-yolov9c.onnx?download=1" @@ -82,17 +93,22 @@ AUDIO_BIRDS_MODEL_DIR_NAME="1" AUDIO_BIRDS_MODEL_FILENAME_TEMP="MD_AudioBirds_V1.onnx" AUDIO_BIRDS_MODEL_FILENAME_FINAL="model.onnx" -REPO_URL="https://github.com/microsoft/sparrow-client" +REPO_URL="https://github.com/microsoft/SPARROW.git" CLONE_DIR="" +USE_ROBIN=false -ONBOARDING_URL="https://server.sparrow-earth.com/onboarding" - -USERNAME="" +ONBOARDING_URL="https://server.sparrowstudio.azure.com/v1/onboarding" +EMAIL="" -# ENV / FTP ENV_FILE="" FTP_PASS="" +I2C_BUS="1" + +WP5_URL="https://www.uugear.com/repo/WittyPi5/wp5_latest.deb" +WP5_DEB="/tmp/wp5_latest.deb" +WP5_ADDR="0x51" + ############################################################################### # 2. -- UTILITY FUNCTIONS -- ############################################################################### @@ -110,9 +126,11 @@ install_uuidgen() { generate_unique_id() { [[ -s "$UUID_FILE" ]] && { log "UUID exists: $(cat "$UUID_FILE")"; return; } + local NEW_UUID NEW_UUID=$(uuidgen) echo "$NEW_UUID" | tee "$UUID_FILE" >/dev/null - chmod 644 "$UUID_FILE"; chown root:root "$UUID_FILE" + chmod 644 "$UUID_FILE" + chown root:root "$UUID_FILE" log "Generated UUID: $NEW_UUID" } @@ -124,21 +142,12 @@ get_hardware_id() { local uuid uuid=$(tr -d '\r\n' <"$UUID_FILE") - - if [[ -z "$uuid" ]]; then - log "UUID in $UUID_FILE is empty" - return 1 - fi + [[ -n "$uuid" ]] || { log "UUID in $UUID_FILE is empty"; return 1; } local hwid hwid=$(printf '%s' "$uuid" | sha256sum | awk '{print substr($1,1,12)}') + [[ -n "$hwid" ]] || { log "Failed to derive hardware id from UUID"; return 1; } - if [[ -z "$hwid" ]]; then - log "Failed to derive hardware id from UUID" - return 1 - fi - - # Log to file, but don't let log()'s stdout leak into command substitution log "Generated Hardware ID for onboarding: $hwid" >/dev/null echo "$hwid" } @@ -147,25 +156,156 @@ install_curl() { command_exists curl || { apt-get update -y; apt-get install - install_wget() { command_exists wget || { apt-get update -y; apt-get install -y wget; } } install_git() { command_exists git || { apt-get update -y; apt-get install -y git; } } -install_docker() { +############################################################################### +# Docker / Compose +############################################################################### +install_docker_pi_debian() { + log "Installing Docker Engine + Compose from Docker's official Debian repo..." + + apt-get remove -y docker.io docker-compose docker-doc podman-docker containerd runc || true + + apt-get update -y + apt-get install -y ca-certificates curl gnupg + + install -m 0755 -d /etc/apt/keyrings + + if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + fi + + local arch codename + arch="$(dpkg --print-architecture)" + codename="$(. /etc/os-release && echo "$VERSION_CODENAME")" + + cat >/etc/apt/sources.list.d/docker.list <>"$cfg" + changed=true + log "Added dtparam=i2c_arm=on to $cfg" + fi + + mkdir -p /etc/modules-load.d + if [[ ! -f /etc/modules-load.d/i2c-dev.conf ]] || ! grep -qE '^\s*i2c-dev\s*$' /etc/modules-load.d/i2c-dev.conf; then + echo "i2c-dev" >>/etc/modules-load.d/i2c-dev.conf + changed=true + log "Ensured i2c-dev loads at boot" + fi + apt-get update -y - apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] \ - https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ - | tee /etc/apt/sources.list.d/docker.list >/dev/null + apt-get install -y i2c-tools || true + modprobe i2c-dev || true + + if $changed; then + _info "I2C has been enabled in firmware config.\n\nA reboot is required before /dev/i2c-1 is guaranteed to appear.\nReboot now, then rerun this script." + exit 0 + fi +} + +enable_ds3231_overlay_pi() { + local cfg + cfg=$(_pi_boot_config_path) || { _error "Could not find Pi boot config.txt"; return 1; } + + if ! grep -qE '^\s*dtoverlay=i2c-rtc,ds3231\s*$' "$cfg"; then + echo "dtoverlay=i2c-rtc,ds3231" >>"$cfg" + log "Added dtoverlay=i2c-rtc,ds3231 to $cfg" + _info "DS3231 overlay added.\n\nReboot is required. Reboot, then rerun this script." + exit 0 + fi +} + +############################################################################### +# Witty Pi 5 +############################################################################### +install_wittypi5() { + log "Installing Witty Pi 5 software..." apt-get update -y - apt-get install -y docker-ce docker-ce-cli containerd.io + apt-get install -y wget i2c-tools ca-certificates + rm -f "$WP5_DEB" + wget -O "$WP5_DEB" "$WP5_URL" + apt-get install -y "$WP5_DEB" +} + +detect_wittypi5() { + log "Checking for Witty Pi 5 on I2C..." + if ! i2cget -y "$I2C_BUS" "$WP5_ADDR" 0 >/dev/null 2>&1; then + _error "Witty Pi 5 not detected on I2C bus $I2C_BUS at address $WP5_ADDR. +Check the HAT seating and make sure power goes into the Witty Pi board." + exit 1 + fi } -install_docker_compose() { - rm -f "$DOCKER_COMPOSE_PATH" "$SYMLINK_PATH" 2>/dev/null || true - curl -L "$DOCKER_COMPOSE_URL" -o "$DOCKER_COMPOSE_PATH" - chmod +x "$DOCKER_COMPOSE_PATH" - ln -sf "$DOCKER_COMPOSE_PATH" "$SYMLINK_PATH" +configure_wittypi5_auto_power_on() { + log "Configuring Witty Pi 5 auto power-on after power restore..." + i2cset -y "$I2C_BUS" "$WP5_ADDR" 17 0 } -# Minimal prompt: ask twice, ensure match (no strength rules) +verify_wittypi5_auto_power_on() { + log "Verifying Witty Pi 5 auto power-on setting..." + local value + value="$(i2cget -y "$I2C_BUS" "$WP5_ADDR" 17)" + log "Witty Pi 5 register 17 = $value" + + if [[ "$value" != "0x00" ]]; then + _error "Failed to configure Witty Pi 5 auto power-on. Register 17 is $value, expected 0x00." + exit 1 + fi +} + +############################################################################### +# Hotspot +############################################################################### prompt_hotspot_password() { local p1 p2 while true; do @@ -180,7 +320,6 @@ prompt_hotspot_password() { _error "Passwords do not match. Please try again." continue fi - # Basic WPA length guard so nmcli doesn't fail immediately if (( ${#p1} < 8 || ${#p1} > 63 )); then _error "WPA2-PSK password must be between 8 and 63 characters." continue @@ -198,8 +337,12 @@ setup_persistent_wifi_hotspot() { return 1 fi + if ! command_exists nmcli; then + _error "nmcli not found. NetworkManager must be installed for hotspot setup." + return 1 + fi + if nmcli connection show "$ssid" >/dev/null 2>&1; then - # Update PSK in case user changed it on a re-run nmcli connection modify "$ssid" wifi-sec.psk "$pw" nmcli -t -f NAME connection show --active | grep -qx "$ssid" || nmcli connection up "$ssid" else @@ -211,228 +354,150 @@ setup_persistent_wifi_hotspot() { fi } +############################################################################### +# Models + repo +############################################################################### download_model() { - local dir="$SYSTEM_FOLDER/Models/tritonserver/model_repository/megadetectorv6/$MODEL_DIR_NAME" + local dir="$SYSTEM_FOLDER/Models/megadetectorv6/$MODEL_DIR_NAME" local tmp="$dir/$MODEL_FILENAME_TEMP" fin="$dir/$MODEL_FILENAME_FINAL" mkdir -p "$dir" while true; do if _progress "Downloading Megadetector v6..." wget -q -O "$tmp" "$MODEL_DOWNLOAD_URL"; then - mv "$tmp" "$fin"; break + mv "$tmp" "$fin" + break fi _yesno "Megadetector download failed. Retry?" || { _error "Aborted."; exit 1; } done } download_model_ai4g() { - local dir="$SYSTEM_FOLDER/Models/tritonserver/model_repository/AI4GAmazonClassification/$AI4G_MODEL_DIR_NAME" + local dir="$SYSTEM_FOLDER/Models/AI4GAmazonClassification/$AI4G_MODEL_DIR_NAME" local tmp="$dir/$AI4G_MODEL_FILENAME_TEMP" fin="$dir/$AI4G_MODEL_FILENAME_FINAL" mkdir -p "$dir" while true; do if _progress "Downloading AI4G model..." wget -q -O "$tmp" "$AI4G_MODEL_DOWNLOAD_URL"; then - mv "$tmp" "$fin"; break + mv "$tmp" "$fin" + break fi _yesno "AI4G model download failed. Retry?" || { _error "Aborted."; exit 1; } done } download_model_audio_birds() { - local dir="$SYSTEM_FOLDER/Models/tritonserver/model_repository/megadetector_birds_v1/$AUDIO_BIRDS_MODEL_DIR_NAME" + local dir="$SYSTEM_FOLDER/Models/megadetector_birds_v1/$AUDIO_BIRDS_MODEL_DIR_NAME" local tmp="$dir/$AUDIO_BIRDS_MODEL_FILENAME_TEMP" fin="$dir/$AUDIO_BIRDS_MODEL_FILENAME_FINAL" mkdir -p "$dir" while true; do if _progress "Downloading MD Audio Birds v1..." wget -q -O "$tmp" "$AUDIO_BIRDS_MODEL_DOWNLOAD_URL"; then - mv "$tmp" "$fin"; break + mv "$tmp" "$fin" + break fi _yesno "Audio Birds model download failed. Retry?" || { _error "Aborted."; exit 1; } done } -clone_public_repo() { - CLONE_DIR="$SYSTEM_FOLDER" # project root = system/ +clone_repo() { + CLONE_DIR="$SYSTEM_FOLDER" local tmpdir tmpdir="$(mktemp -d)" - _progress "Cloning repo..." git clone "$REPO_URL" "$tmpdir" + + log "Cloning $REPO_URL..." + + if ! git clone --depth=1 "$REPO_URL" "$tmpdir"; then + rm -rf "$tmpdir" + _error "Failed to clone $REPO_URL. Check network and that the URL is correct." + exit 1 + fi + ( shopt -s dotglob cp -a "$tmpdir"/* "$CLONE_DIR"/ ) + rm -rf "$tmpdir" create_additional_directories } +prompt_robin_usage() { + if _yesno "Is ROBIN being used?"; then + USE_ROBIN=true + else + USE_ROBIN=false + fi +} + +detect_xbee_port() { + local port="" + + if [[ -d /dev/serial/by-id ]]; then + for dev in /dev/serial/by-id/*; do + [[ -e "$dev" ]] || continue + port="$(readlink -f "$dev")" + [[ -n "$port" ]] && break + done + fi + + if [[ -z "$port" ]]; then + for dev in /dev/ttyUSB* /dev/ttyACM*; do + [[ -e "$dev" ]] || continue + port="$dev" + break + done + fi + + if [[ -z "$port" ]]; then + _error "Could not auto-detect XBee serial device." + return 1 + fi + + echo "$port" +} + +run_xbee_configure_if_needed() { + [[ "${USE_ROBIN:-false}" == "true" ]] || { + log "ROBIN not in use; skipping xbee_configure.py" + return 0 + } + + local xbee_port + xbee_port="$(detect_xbee_port)" || exit 1 + + log "Running xbee_configure.py for ROBIN on port $xbee_port..." + ( + cd "$SYSTEM_FOLDER/sparrow" + python xbee_configure.py \ + --port "$xbee_port" \ + --baud 115200 \ + --api 1 \ + --rf 1 \ + --router 0 \ + --netid 1234 \ + --node SPARROW_MASTER + ) +} + create_additional_directories() { - # ensure runtime dirs under sparrow/ and starlink/ mkdir -p "$SYSTEM_FOLDER/sparrow"/{logs,images,recordings,static/data,static/gallery,config} mkdir -p "$SYSTEM_FOLDER/starlink"/{logs,config} } create_folders() { - local uh; uh=$(eval echo ~"${SUDO_USER:-$USER}") + local uh + uh=$(eval echo ~"${SUDO_USER:-$USER}") SYSTEM_FOLDER="$uh/Desktop/system" - mkdir -p "$SYSTEM_FOLDER/Models/tritonserver/model_repository/megadetectorv6/$MODEL_DIR_NAME" - mkdir -p "$SYSTEM_FOLDER/Models/tritonserver/model_repository/AI4GAmazonClassification/$AI4G_MODEL_DIR_NAME" - mkdir -p "$SYSTEM_FOLDER/Models/tritonserver/model_repository/megadetector_birds_v1/$AUDIO_BIRDS_MODEL_DIR_NAME" - CLONE_DIR="$SYSTEM_FOLDER" # compose/project root - -# config.pbtxt - Megadetector -cat >"$SYSTEM_FOLDER/Models/tritonserver/model_repository/megadetectorv6/config.pbtxt" <<'EOF' -name: "megadetectorv6" -platform: "onnxruntime_onnx" -max_batch_size: 0 - -input [ - { - name: "images" - data_type: TYPE_FP32 - dims: [1, 3, 640, 640] - } -] - -output [ - { - name: "output0" - data_type: TYPE_FP32 - dims: [-1, -1, -1] - } -] - -parameters { - key: "intra_op_num_threads" - value: { string_value: "2" } -} -parameters { - key: "inter_op_num_threads" - value: { string_value: "2" } -} -parameters { - key: "execution_mode" - value: { string_value: "1" } # 1=parallel, 0=sequential -} -parameters { - key: "enable_cpu_mem_arena" - value: { string_value: "1" } -} -parameters { - key: "enable_mem_pattern" - value: { string_value: "1" } -} - -instance_group [ - { - kind: KIND_GPU - gpus: [0] - count: 1 - } -] -EOF -# config.pbtxt - AI4G Amazon Classificaion Model -cat >"$SYSTEM_FOLDER/Models/tritonserver/model_repository/AI4GAmazonClassification/config.pbtxt" <<'EOF' -name: "AI4GAmazonClassification" -platform: "onnxruntime_onnx" -max_batch_size: 0 - -input [ - { - name: "input" - data_type: TYPE_FP32 - dims: [-1, 3, 224, 224] - } -] - -output [ - { - name: "output" - data_type: TYPE_FP32 - dims: [-1, 36] - } -] - -parameters { - key: "intra_op_num_threads" - value: { string_value: "2" } -} -parameters { - key: "inter_op_num_threads" - value: { string_value: "2" } -} -parameters { - key: "execution_mode" - value: { string_value: "1" } # 1=parallel, 0=sequential -} -parameters { - key: "enable_cpu_mem_arena" - value: { string_value: "1" } -} -parameters { - key: "enable_mem_pattern" - value: { string_value: "1" } -} - -instance_group [ - { - kind: KIND_GPU - gpus: [0] - count: 1 - } -] -EOF + mkdir -p "$SYSTEM_FOLDER/Models/megadetectorv6/$MODEL_DIR_NAME" + mkdir -p "$SYSTEM_FOLDER/Models/AI4GAmazonClassification/$AI4G_MODEL_DIR_NAME" + mkdir -p "$SYSTEM_FOLDER/Models/megadetector_birds_v1/$AUDIO_BIRDS_MODEL_DIR_NAME" + + CLONE_DIR="$SYSTEM_FOLDER" -# config.pbtxt - MD Audio Birds v1 -cat >"$SYSTEM_FOLDER/Models/tritonserver/model_repository/megadetector_birds_v1/config.pbtxt" <<'EOF' -name: "megadetector_birds_v1" -backend: "onnxruntime" -max_batch_size: 32 - -input [ - { - name: "input" - data_type: TYPE_FP32 - dims: [1, 224, -1] - } -] - -output [ - { - name: "logits" - data_type: TYPE_FP32 - dims: [1] - } -] - -parameters { - key: "intra_op_num_threads" - value: { string_value: "2" } -} -parameters { - key: "inter_op_num_threads" - value: { string_value: "2" } -} -parameters { - key: "execution_mode" - value: { string_value: "1" } # 1 = parallel, 0 = sequential -} -parameters { - key: "enable_cpu_mem_arena" - value: { string_value: "1" } -} -parameters { - key: "enable_mem_pattern" - value: { string_value: "1" } -} - -instance_group [ - { - kind: KIND_GPU - gpus: [0] - count: 1 - } -] -EOF download_model download_model_ai4g download_model_audio_birds - clone_public_repo + clone_repo + prompt_robin_usage + run_xbee_configure_if_needed } install_smbus2() { @@ -442,7 +507,7 @@ install_smbus2() { } ############################################################################### -# 2b. -- FTP_PASS PROMPT + .env UPDATE -- +# FTP_PASS ############################################################################### prompt_ftp_pass() { local p1 p2 @@ -468,7 +533,6 @@ set_dotenv_kv() { mkdir -p "$(dirname "$file")" [[ -f "$file" ]] || touch "$file" - # escape for sed replacement local esc esc=$(printf '%s' "$val" | sed -e 's/[\/&]/\\&/g') @@ -480,17 +544,13 @@ set_dotenv_kv() { } find_env_file() { - # Prefer standard docker-compose dotenv naming in the compose root if [[ -f "$SYSTEM_FOLDER/.env" ]]; then echo "$SYSTEM_FOLDER/.env"; return 0 fi - - # Common alternative naming if [[ -f "$SYSTEM_FOLDER/sparrow.env" ]]; then echo "$SYSTEM_FOLDER/sparrow.env"; return 0 fi - # Heuristic: any *.env in root containing at least one known key local f for f in "$SYSTEM_FOLDER"/*.env "$SYSTEM_FOLDER"/*env; do [[ -f "$f" ]] || continue @@ -498,125 +558,23 @@ find_env_file() { echo "$f"; return 0 fi done - return 1 } configure_ftp_pass_in_env() { - ENV_FILE="$SYSTEM_FOLDER/sparrow.env" + if ENV_FILE=$(find_env_file); then + : + else + ENV_FILE="$SYSTEM_FOLDER/sparrow.env" + fi [[ -f "$ENV_FILE" ]] || { _error "Env file not found: $ENV_FILE"; return 1; } + set_dotenv_kv "$ENV_FILE" "FTP_PASS" "$FTP_PASS" log "Set FTP_PASS in env file: $ENV_FILE" } - -############################################################################### -# 3. -- DS3231 RTC SEED -- -############################################################################### -seed_ds3231() { - log "Seeding DS3231 RTC with current UTC time..." - max_retries=5 - attempt=1 - current_time="" - - # 1) Try World Clock API - while [ $attempt -le $max_retries ]; do - log "Attempt $attempt/$max_retries: fetching UTC from World Clock API..." - raw=$(curl -sL http://worldclockapi.com/api/json/utc/now) - current_time=$(echo "$raw" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("currentDateTime",""))' 2>/dev/null \ - || true) - - if [ -n "$current_time" ]; then - current_time=$(echo "$current_time" | sed 's/T/ /; s/Z//'):00 - log "Retrieved UTC time from API: $current_time" - break - fi - - log "World Clock API fetch failed on attempt $attempt." - attempt=$((attempt+1)) - sleep 2 - done - - # 2) Fallback to system NTP if API never returned a time - if [ -z "$current_time" ]; then - log "World Clock API failed after $max_retries attempts. Falling back to system NTP sync." - echo "World Clock API failed; using system NTP to obtain UTC time." - - if ! command_exists timedatectl; then - log "timedatectl not foundβ€”installing systemd-timesyncd..." - apt-get update -y || log "Warning: apt-get update failed, continuing" - apt-get install -y systemd-timesyncd || log "Warning: systemd-timesyncd install failed, continuing" - fi - - if ! timedatectl set-ntp true; then - log "Warning: timedatectl set-ntp failedβ€”continuing with local system time" - current_time=$(date -u +"%Y-%m-%d %H:%M:%S") - log "Using local UTC time: $current_time" - else - sync_max=5 - for i in $(seq 1 $sync_max); do - if [ "$(timedatectl show -p NTPSynchronized --value)" = "yes" ]; then - current_time=$(date -u +"%Y-%m-%d %H:%M:%S") - log "Retrieved UTC time via NTP: $current_time" - break - fi - log "Waiting for NTP synchronization ($i/$sync_max)…" - sleep 2 - done - if [ -z "$current_time" ]; then - log "Warning: NTP never synchronizedβ€”using local UTC time" - current_time=$(date -u +"%Y-%m-%d %H:%M:%S") - log "Local UTC time: $current_time" - fi - fi - fi - - # 3) Write to the DS3231, with its own retry loop - write_attempts=1 - while [ $write_attempts -le $max_retries ]; do - if python3 - <"$SYSTEM_FOLDER/sparrow/config/access_key.txt" echo "$k1" >"$SYSTEM_FOLDER/starlink/config/access_key.txt" - # Prompt for username (in-memory only) while true; do - USERNAME=$(_input "Enter Sparrow Username") - if [[ -z "$USERNAME" ]]; then - _error "Username cannot be empty - try again." + EMAIL=$(_input "Enter Sparrow Email") + if [[ -z "$EMAIL" ]]; then + _error "Email cannot be empty - try again." continue fi break @@ -645,43 +602,25 @@ onboard_device() { access_key_file="$SYSTEM_FOLDER/sparrow/config/access_key.txt" - if [[ ! -f "$access_key_file" ]]; then - log "Access key file not found at $access_key_file" - _error "Cannot onboard: access key not found." - return 1 - fi + [[ -f "$access_key_file" ]] || { log "Access key file not found at $access_key_file"; _error "Cannot onboard: access key not found."; return 1; } - # Strip any line endings from the key api_key=$(tr -d '\r\n' <"$access_key_file") + [[ -n "$api_key" ]] || { _error "Access key file is empty; cannot onboard."; return 1; } + [[ -n "$EMAIL" ]] || { _error "Email is not set in memory; cannot onboard."; return 1; } - if [[ -z "$api_key" ]]; then - _error "Access key file is empty; cannot onboard." - return 1 - fi - - if [[ -z "$USERNAME" ]]; then - _error "Username is not set in memory; cannot onboard." - return 1 - fi - - hwid=$(get_hardware_id) || { - _error "Cannot compute hardware id for onboarding." - return 1 - } + hwid=$(get_hardware_id) || { _error "Cannot compute hardware id for onboarding."; return 1; } - # Build JSON payload safely (no shell interpolation weirdness) json_payload=$(printf '{"unit_id":"%s"}' "$hwid") - log "Onboarding JSON payload (len=${#json_payload}): $json_payload" while (( attempt <= max_retries )); do - log "Onboarding attempt $attempt/$max_retries for unit_id=$hwid (username=$USERNAME)" + log "Onboarding attempt $attempt/$max_retries for unit_id=$hwid (email=$EMAIL)" resp=$(curl -sS -w "%{http_code}" \ -X POST "$ONBOARDING_URL" \ -H "Content-Type: application/json" \ -H "X-API-Key: $api_key" \ - -H "X-Username: $USERNAME" \ + -H "X-Email: $EMAIL" \ --data-binary "$json_payload" 2>/dev/null || true) status_code=${resp: -3} @@ -709,99 +648,123 @@ onboard_device() { } ############################################################################### -# 4. -- MAIN SCRIPT LOGIC -- +# Auto-updater +############################################################################### +install_auto_updater() { + local installer="$SYSTEM_FOLDER/updater/install.sh" + if [[ ! -f "$installer" ]]; then + _error "Auto-updater installer not found at $installer; skipping." + return 1 + fi + log "Installing SPARROW auto-updater (systemd timer, every 15 min, tag-based)..." + if bash "$installer"; then + log "Auto-updater installed and enabled." + else + _error "Auto-updater install failed; continuing setup. Re-run manually: sudo bash $installer" + return 1 + fi +} + +############################################################################### +# MAIN ############################################################################### [ "$EUID" -eq 0 ] || { _error "Run as root (sudo)."; exit 1; } _install_zenity -touch "$LOG_FILE"; chmod 644 "$LOG_FILE" +touch "$LOG_FILE" +chmod 644 "$LOG_FILE" +exec > >(tee -a "$LOG_FILE") 2>&1 -_info "Welcome to the SPARROW Setup Wizard!\nThis will install all prerequisites, download the required model and start SPARROW." +_info "Welcome to the SPARROW Setup Wizard (Raspberry Pi 5 / Debian Trixie)!\nThis will install prerequisites, download the required models and start SPARROW." -_yesno "Have you installed the OS directly onto the SSD using NVIDIA SDK Manager?" \ - || { _error "Please flash the OS first, then rerun."; exit 1; } +_yesno "Are you running this on a Raspberry Pi 5 (Debian Trixie) with internet access?" \ + || { _error "Please prepare the Pi OS first, then rerun."; exit 1; } _info "Checking internet connectivity. Press ok to continue" ping -c3 8.8.8.8 >/dev/null 2>&1 || { _error "No internet connection."; exit 1; } -install_wget; install_git; create_folders -install_uuidgen; generate_unique_id; install_curl +enable_i2c_pi +if _yesno "Enable DS3231 device-tree overlay (dtoverlay=i2c-rtc,ds3231) in boot config?"; then + enable_ds3231_overlay_pi +fi + +install_wget +install_git +install_curl +install_network_manager -if command_exists docker; then log "Docker already installed." -else _progress "Installing Docker..." install_docker; fi +install_wittypi5 +detect_wittypi5 +configure_wittypi5_auto_power_on +verify_wittypi5_auto_power_on -if command_exists docker-compose; then - cur=$(docker-compose --version | awk '{print $4}' | tr -d ',v') - [[ "$cur" == "$DOCKER_COMPOSE_VERSION" ]] || _progress "Updating Docker-Compose..." install_docker_compose -else _progress "Installing Docker-Compose..." install_docker_compose; fi +create_folders +install_uuidgen +generate_unique_id + +if command_exists docker && docker compose version >/dev/null 2>&1; then + log "Docker Engine and Docker Compose already installed." +else + _progress "Installing Docker Engine + Compose..." install_docker_pi_debian +fi -# prompt for hotspot password before configuring Wi-Fi AP prompt_hotspot_password setup_persistent_wifi_hotspot -# prompt for FTP_PASS and write it into the env file before containers run prompt_ftp_pass configure_ftp_pass_in_env install_smbus2 -seed_ds3231 configure_access_key onboard_device || log "Onboarding did not complete successfully; continuing setup." -if _yesno "Install TeamViewer Host (ARM64)?" ; then - _progress "Installing TeamViewer Host..." bash -c ' - wget -qO /tmp/tv.deb https://download.teamviewer.com/download/linux/teamviewer-host_arm64.deb && - apt-get install -y /tmp/tv.deb && - systemctl enable teamviewerd.service && - teamviewer passwd Sparrow0102!' -fi - log "Building Sparrow containers..." cd "$SYSTEM_FOLDER" -# Enable Docker BuildKit + plain CLI progress export COMPOSE_DOCKER_CLI_BUILD=1 export DOCKER_BUILDKIT=1 if $GUI && command -v zenity >/dev/null; then - log "docker-compose build (GUI+console) with BuildKit + no-cache started" - docker-compose build --no-cache --progress=plain 2>&1 \ + log "docker compose build (GUI+console) with BuildKit + no-cache started" + docker_compose_cmd build --no-cache --progress=plain 2>&1 \ | tee /dev/tty \ | zenity --progress --pulsate --no-cancel --auto-close \ --text="Building Docker images (no cache)…" --width=480 - log "docker-compose build (GUI+console) with BuildKit + no-cache completed" + log "docker compose build (GUI+console) with BuildKit + no-cache completed" else - log "docker-compose build (CLI) with BuildKit + no-cache started" - docker-compose build --no-cache --progress=plain - log "docker-compose build (CLI) with BuildKit + no-cache completed" + log "docker compose build (CLI) with BuildKit + no-cache started" + docker_compose_cmd build --no-cache --progress=plain + log "docker compose build (CLI) with BuildKit + no-cache completed" fi -# 1) Start in detached mode, showing pull/start logs in a Zenity window (or CLI) if $GUI && command -v zenity >/dev/null; then - log "docker-compose up (GUI+console) started" - docker-compose up -d 2>&1 \ + log "docker compose up (GUI+console) started" + docker_compose_cmd up -d 2>&1 \ | tee /dev/tty \ | zenity --text-info \ --title="Starting Sparrow Containers" \ --width=800 --height=600 \ --font="Monospace 10" - log "docker-compose up (GUI+console) completed" + log "docker compose up (GUI+console) completed" else - _progress "Starting Sparrow.." docker-compose up -d + _progress "Starting Sparrow.." docker_compose_cmd up -d log "Sparrow containers started" fi -# 2) Follow the logs in real time +if _yesno "Enable auto-update from GitHub tags (recommended for field deployments)?"; then + install_auto_updater || log "Auto-updater install did not complete; continuing." +fi + log "Tailing Sparrow logs (Ctrl-C to exit)..." if $GUI && command -v zenity >/dev/null; then - docker-compose logs --no-color --follow \ + docker_compose_cmd logs --no-color --follow \ | tee /dev/tty \ | zenity --text-info \ --title="Sparrow Container Logs" \ --width=800 --height=600 \ --font="Monospace 10" else - docker-compose logs --tail=100 --follow + docker_compose_cmd logs --tail=100 --follow fi -_info "Setup completed - Sparrow is now running!\n\nTo follow logs:\n cd $SYSTEM_FOLDER && docker-compose logs -f" +_info "Setup completed - Sparrow is now running!\n\nTo follow logs:\n cd $SYSTEM_FOLDER && docker compose logs -f" exit 0 diff --git a/sparrow.env b/sparrow.env index f763ad1..5451024 100644 --- a/sparrow.env +++ b/sparrow.env @@ -3,11 +3,10 @@ STARTUP_AUTOLOAD=true UNLOAD_REMOVED=false UNIQUE_ID_PATH=/host/etc/unique_id LOCAL_MODELS_DIR=/home/sparrow/Desktop/system/Models/tritonserver/model_repository -VEDIRECT_PORT=/dev/ttyUSB0 -VEDIRECT_BAUD=19200 -SERVER_BASE_URL=https://server.sparrow-earth.com +VE_DIRECT_PORT=/dev/serial/by-id/usb-VictronEnergy_VE.Direct_cable-if00-port0 +SERVER_BASE_URL=https://server.sparrowstudio.azure.com/v1 TZ=Etc/UTC -ONLY_SAVE_ANIMALS=true +ONLY_SAVE_ANIMALS=false FTP_USER=camera FTP_PASS= DRAW_BOXES=true \ No newline at end of file diff --git a/sparrow/Dockerfile b/sparrow/Dockerfile index 9382d9a..1448b11 100644 --- a/sparrow/Dockerfile +++ b/sparrow/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim-bookworm +FROM python:3.12-slim-bookworm # Install system build tools and libraries RUN apt-get update && apt-get install -y \ @@ -24,7 +24,7 @@ RUN apt-get update && apt-get install -y \ RUN pip install --upgrade pip setuptools wheel # Install compatible Cython FIRST so pip doesn't pull a different one in the build env -RUN pip install cython==0.29.36 +RUN pip install cython==3.0.11 # Copy and install all other dependencies COPY requirements.txt . diff --git a/sparrow/audio.py b/sparrow/audio.py index b4a3516..a0815f6 100644 --- a/sparrow/audio.py +++ b/sparrow/audio.py @@ -1,9 +1,24 @@ #!/usr/bin/env python3 """ -Audio monitoring pipeline that records from a USB microphone (threshold- or schedule-based), -slices clips into overlapping windows, generates mel-spectrograms, and sends batches to a Triton model for inference. -Window scores are aggregated to per-second and audio-level results; recordings with confidence -> SUMMARY_THRESHOLD are kept and appended to /app/static/data/audio_detections.csv. -Settings are loaded from /app/config/audio_settings.json and periodically refreshed from SERVER_BASE_URL using the device unique_id. +Audio monitoring pipeline (ONNXRuntime-only). + +Records from a USB microphone (threshold- or schedule-based), +slices clips into overlapping windows, generates mel-spectrograms, +and sends batches to a local ONNX model for inference. + +Window scores are aggregated to per-second and audio-level results; +recordings with confidence -> SUMMARY_THRESHOLD are kept and appended to +/app/static/data/audio_detections.csv. + +Settings are loaded from /app/config/audio_settings.json and periodically refreshed +from SERVER_BASE_URL using the device unique_id. + +Required env: + LOCAL_MODELS_DIR -> root folder containing ONNX models: + //1/model.onnx + +Optional env: + SERVER_BASE_URL (defaults to https://server.sparrowstudio.azure.com/v1) """ import os @@ -30,10 +45,21 @@ import torchaudio from torch.utils.data import Dataset, DataLoader from tqdm import tqdm -import tritonclient.http as httpclient + from utils.dataset_dataloader import ResizeTo from utils.sparrow_id import get_hardware_id +# ----------------- ONNXRuntime only ----------------- +try: + import onnxruntime as ort # type: ignore + + HAVE_ORT = True +except ImportError: + HAVE_ORT = False + ort = None + +LOCAL_MODELS_DIR = os.getenv("LOCAL_MODELS_DIR", "").strip() or None + # Configuration Paths CONFIG_DIR = "/app/config" CONFIG_FILE = os.path.join(CONFIG_DIR, "audio_settings.json") @@ -51,16 +77,13 @@ "RECORD_DURATION": 2, "THRESHOLD": 2500, "selected_model": "megadetector_birds_v1", - "SCHEDULE": { - "interval_minutes": 5, - "duration_minutes": 1 - } + "SCHEDULE": {"interval_minutes": 5, "duration_minutes": 1}, } # Pipeline / Model Config BATCH_SIZE = 16 NUM_WORKERS = 0 -SAMPLE_RATE = 48_000 # pipeline sample rate for windows/spectrograms +SAMPLE_RATE = 48_000 # pipeline sample rate for windows/spectrograms WINDOW_SIZE_SEC = 5.0 OVERLAP_SEC = 4.0 N_FFT = 2048 @@ -68,11 +91,6 @@ N_MELS = 224 TOP_DB = 80.0 -# Triton Server -TRITON_URL = (os.getenv("TRITON_SERVER_URL") or os.getenv("TRITON_URL", "http://triton:8000")).strip().rstrip("/") -if TRITON_URL.startswith(("http://", "https://")): - TRITON_URL = TRITON_URL.split("://", 1)[1] - SELECTED_MODEL = DEFAULT_CONFIG["selected_model"] # Audio-level summary threshold (probability) @@ -93,8 +111,11 @@ ) logger = logging.getLogger(__name__) logger.info(f"CSV will be written to: {CSV_PATH}") +logger.info(f"ONNXRuntime-only mode. HAVE_ORT={HAVE_ORT}, LOCAL_MODELS_DIR={LOCAL_MODELS_DIR}") + +# ----------------- Configuration Management ----------------- + -# Configuration Management def load_config(): if not os.path.exists(CONFIG_FILE): logger.info("Configuration file not found. Creating default configuration.") @@ -116,6 +137,7 @@ def load_config(): logger.error(f"Unexpected error loading config: {e}. Using defaults.") return DEFAULT_CONFIG.copy() + def save_config(config): try: with FileLock(f"{CONFIG_FILE}.lock", timeout=5): @@ -128,6 +150,7 @@ def save_config(config): except Exception as e: logger.error(f"Unexpected error while saving config: {e}") + # Generate Unique ID (using shared sparrow_id.py) try: UNIQUE_ID = get_hardware_id() @@ -137,7 +160,7 @@ def save_config(config): raise SystemExit(1) # Configuration Fetching -SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrow-earth.com").rstrip("/") +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrowstudio.azure.com/v1").rstrip("/") SERVER_URL = f"{SERVER_BASE_URL}/get_scheduleaudio" try: @@ -145,13 +168,15 @@ def save_config(config): AUTH_KEY = f.read().strip() except Exception as e: logger.error(f"Failed to read access key from /app/config/access_key.txt: {e}") - exit(1) + raise SystemExit(1) + def fetch_settings(unique_id): payload = {"unique_id": unique_id, "auth_key": AUTH_KEY} + headers = {"X-API-Key": AUTH_KEY, "X-Unit-ID": unique_id} try: logger.info("Attempting to fetch audio settings from the server.") - response = requests.post(SERVER_URL, json=payload, timeout=10) + response = requests.post(SERVER_URL, json=payload, headers=headers, timeout=10) if response.status_code == 200: server_settings = response.json() logger.info(f"Audio settings retrieved from server: {server_settings}") @@ -167,7 +192,8 @@ def fetch_settings(unique_id): except requests.exceptions.RequestException as e: logger.error(f"Error while fetching settings from server: {e}") -# Audio Monitoring Globals + +# ----------------- Audio Monitoring Globals ----------------- # capture/recorder (device) settings - populated by update_audio_settings() CAPTURE_SR = None MONITOR_DURATION = None @@ -183,6 +209,7 @@ def fetch_settings(unique_id): INTERVAL_SEC = 60 * 60 DURATION_SEC = 10 * 60 + # Microphone Detection def detect_usb_microphone(): try: @@ -192,7 +219,7 @@ def detect_usb_microphone(): if "card" in ll and "device" in ll and "usb" in ll: try: card = line.split("card")[1].split(":")[0].strip() - dev = line.split("device")[1].split(":")[0].strip() + dev = line.split("device")[1].split(":")[0].strip() dev_id = f"plughw:{card},{dev}" logger.info(f"Detected USB audio at {dev_id}") return dev_id @@ -203,17 +230,18 @@ def detect_usb_microphone(): logger.error(f"Error detecting audio device: {e}") return None + # Update audio settings def update_audio_settings(): global CAPTURE_SR, MONITOR_DURATION, RECORD_DURATION, THRESHOLD global DEVICE, MODE, INTERVAL_SEC, DURATION_SEC, SELECTED_MODEL config = load_config() - CAPTURE_SR = int(config.get("SAMPLE_RATE", DEFAULT_CONFIG["SAMPLE_RATE"])) + CAPTURE_SR = int(config.get("SAMPLE_RATE", DEFAULT_CONFIG["SAMPLE_RATE"])) MONITOR_DURATION = int(config.get("MONITOR_DURATION", DEFAULT_CONFIG["MONITOR_DURATION"])) - RECORD_DURATION = int(config.get("RECORD_DURATION", DEFAULT_CONFIG["RECORD_DURATION"])) - THRESHOLD = int(config.get("THRESHOLD", DEFAULT_CONFIG["THRESHOLD"])) - SELECTED_MODEL = config.get("selected_model", DEFAULT_CONFIG["selected_model"]) + RECORD_DURATION = int(config.get("RECORD_DURATION", DEFAULT_CONFIG["RECORD_DURATION"])) + THRESHOLD = int(config.get("THRESHOLD", DEFAULT_CONFIG["THRESHOLD"])) + SELECTED_MODEL = config.get("selected_model", DEFAULT_CONFIG["selected_model"]) MODE = config.get("mode", "threshold") sched = config.get("SCHEDULE", {}) @@ -230,10 +258,16 @@ def update_audio_settings(): else: logger.warning("No valid audio device detected.") -# Dataset for .npy specs + +# ----------------- Dataset for .npy specs ----------------- class BioacousticsInferenceDataset(Dataset): - def __init__(self, dataframe: pd.DataFrame, root: Optional[str] = None, - x_col: str = "spec_name", target_size: Optional[List[int]] = None): + def __init__( + self, + dataframe: pd.DataFrame, + root: Optional[str] = None, + x_col: str = "spec_name", + target_size: Optional[List[int]] = None, + ): super().__init__() self.df = dataframe self.root = root @@ -241,7 +275,8 @@ def __init__(self, dataframe: pd.DataFrame, root: Optional[str] = None, self.paths = [os.path.join(self.root, p) for p in self.df[self.x_col].astype(str).tolist()] self._resize = ResizeTo(target_size) if target_size is not None else None - def __len__(self): return len(self.df) + def __len__(self): + return len(self.df) def _load_npy(self, idx: int): path = self.paths[idx] @@ -260,7 +295,8 @@ def __getitem__(self, idx: int): x = self._resize(x) return x, path -# Windowing / Spec creation + +# ----------------- Windowing / Spec creation ----------------- def build_windows_for_file(audio_path, window_size_sec, overlap_sec, sample_rate): window_size = int(window_size_sec * sample_rate) hop_size = int((window_size_sec - overlap_sec) * sample_rate) @@ -272,33 +308,36 @@ def build_windows_for_file(audio_path, window_size_sec, overlap_sec, sample_rate return windows if duration_samples <= window_size: - windows.append({'window_id': 0, 'sound_path': audio_path, 'start': 0, 'end': duration_samples}) + windows.append({"window_id": 0, "sound_path": audio_path, "start": 0, "end": duration_samples}) return windows num_windows = max(1, math.ceil((duration_samples - window_size) / hop_size) + 1) for i in range(num_windows): start = i * hop_size - end = min(start + window_size, duration_samples) # include last partial window - if end <= start: # safety + end = min(start + window_size, duration_samples) + if end <= start: continue - windows.append({'window_id': window_idx, 'sound_path': audio_path, 'start': start, 'end': end}) + windows.append({"window_id": window_idx, "sound_path": audio_path, "start": start, "end": end}) window_idx += 1 return windows -def compute_all_mel_spectrograms_gpu(windows: List[dict], - sample_rate: int, - n_fft: int, - hop_length: Optional[int], - n_mels: int, - top_db: float, - spectrograms_path: str, - save_npy: bool, - fill_noise: bool, - noise_db_mean: Optional[float], - noise_db_std: float, - random_state: Optional[int], - storage_dtype: str) -> None: + +def compute_all_mel_spectrograms_gpu( + windows: List[dict], + sample_rate: int, + n_fft: int, + hop_length: Optional[int], + n_mels: int, + top_db: float, + spectrograms_path: str, + save_npy: bool, + fill_noise: bool, + noise_db_mean: Optional[float], + noise_db_std: float, + random_state: Optional[int], + storage_dtype: str, +) -> None: if hop_length is None: hop_length = n_fft // 4 @@ -323,8 +362,16 @@ def compute_all_mel_spectrograms_gpu(windows: List[dict], wav_cpu = torchaudio.functional.resample(wav_cpu, orig_freq=orig_sr, new_freq=sample_rate) mel_tf = torchaudio.transforms.MelSpectrogram( - sample_rate=sample_rate, n_fft=n_fft, hop_length=hop_length, n_mels=n_mels, - f_min=0.0, f_max=sample_rate / 2.0, power=2.0, center=False, norm="slaney", mel_scale="slaney" + sample_rate=sample_rate, + n_fft=n_fft, + hop_length=hop_length, + n_mels=n_mels, + f_min=0.0, + f_max=sample_rate / 2.0, + power=2.0, + center=False, + norm="slaney", + mel_scale="slaney", ).to(device) to_db = torchaudio.transforms.AmplitudeToDB(stype="power", top_db=top_db).to(device) @@ -348,86 +395,144 @@ def compute_all_mel_spectrograms_gpu(windows: List[dict], if device.type == "cuda": torch.cuda.empty_cache() -# Triton batching (specs -> logits) -def run_inference_triton(dataloader: DataLoader, sample_rate: int) -> Tuple[List[str], np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - client = httpclient.InferenceServerClient(url=TRITON_URL, network_timeout=600.0) - if not client.is_server_live(): - raise RuntimeError("Triton server not live") - md = client.get_model_metadata(SELECTED_MODEL) - in_name, out_name = md["inputs"][0]["name"], md["outputs"][0]["name"] +# ----------------- ONNX inference (specs -> logits) ----------------- +_audio_ort_sessions = {} # model_name -> session + - all_paths, all_logits = [], [] +def _ensure_ort_ready() -> bool: + if not HAVE_ORT or ort is None: + logger.error("onnxruntime is not installed. Audio inference cannot run.") + return False + if not LOCAL_MODELS_DIR: + logger.error("LOCAL_MODELS_DIR is not set. Cannot locate audio ONNX model.") + return False + return True + + +def _get_audio_onnx_session(model_name: str): + if model_name in _audio_ort_sessions: + return _audio_ort_sessions[model_name] + if not _ensure_ort_ready(): + return None + + model_path = os.path.join(LOCAL_MODELS_DIR, model_name, "1", "model.onnx") + if not os.path.isfile(model_path): + logger.error(f"Audio ONNX model not found at: {model_path}") + return None + + logger.info(f"Loading audio ONNX model '{model_name}' from {model_path}") + sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) + _audio_ort_sessions[model_name] = sess + return sess + + +def run_inference_onnx( + dataloader: DataLoader, sample_rate: int +) -> Tuple[List[str], np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Run inference via ONNXRuntime. + Returns: (audios, starts, ends, predictions, probabilities) + """ + sess = _get_audio_onnx_session(SELECTED_MODEL) + if sess is None: + empty_f = np.array([], dtype=np.float32) + empty_i = np.array([], dtype=np.int32) + return [], empty_f, empty_f, empty_i, empty_f + + in_name = sess.get_inputs()[0].name + out_name = sess.get_outputs()[0].name + + all_paths: List[str] = [] + all_logits: List[np.ndarray] = [] for x, paths in tqdm(dataloader, desc="batches", leave=False): - # x: [B, 1, H, W] torch x_np = x.numpy().astype(np.float32, copy=False) - inp = httpclient.InferInput(in_name, x_np.shape, "FP32") - inp.set_data_from_numpy(x_np, binary_data=True) - res = client.infer(model_name=SELECTED_MODEL, inputs=[inp]) - logits = res.as_numpy(out_name) # [B,1] or [B] - logits = np.squeeze(logits, axis=-1) if logits.ndim == 2 and logits.shape[1] == 1 else logits - all_logits.append(logits) - all_paths.extend(paths) + try: + out = sess.run([out_name], {in_name: x_np})[0] + except Exception as e: + logger.error(f"ONNX inference error: {e}") + continue + + logits = np.asarray(out) + # common shapes: [B,1], [B], [B, num_classes] (we expect binary-ish) + if logits.ndim == 2 and logits.shape[1] == 1: + logits = logits[:, 0] + elif logits.ndim > 1 and logits.shape[0] == x_np.shape[0] and logits.shape[-1] == 1: + logits = logits.reshape((logits.shape[0],)) + all_logits.append(logits.astype(np.float32, copy=False)) + all_paths.extend(list(paths)) + + if not all_logits: + empty_f = np.array([], dtype=np.float32) + empty_i = np.array([], dtype=np.int32) + return [], empty_f, empty_f, empty_i, empty_f audios = ["_".join(os.path.basename(p).replace(".npy", "").split("_")[:-2]) for p in all_paths] starts = [int(os.path.basename(p).split("_")[-2]) / sample_rate for p in all_paths] - ends = [int(os.path.basename(p).split("_")[-1].replace(".npy", "")) / sample_rate for p in all_paths] + ends = [int(os.path.basename(p).split("_")[-1].replace(".npy", "")) / sample_rate for p in all_paths] + + logits_all = np.concatenate([np.atleast_1d(a) for a in all_logits]).astype(np.float32, copy=False) + probabilities = 1.0 / (1.0 + np.exp(-logits_all)) if logits_all.size else np.array([], dtype=np.float32) + predictions = (probabilities > 0.5).astype(np.int32) if probabilities.size else np.array([], dtype=np.int32) - all_logits = np.concatenate(all_logits) if len(all_logits) else np.array([], dtype=np.float32) - probabilities = 1 / (1 + np.exp(-all_logits)) if all_logits.size else np.array([], dtype=np.float32) - predictions = (probabilities > 0.5).astype(int) if probabilities.size else np.array([], dtype=np.int32) - return audios, starts, ends, predictions, probabilities + return audios, np.array(starts, dtype=np.float32), np.array(ends, dtype=np.float32), predictions, probabilities -# Aggregations / Summary + +# ----------------- Aggregations / Summary ----------------- def process_inference_results_per_second(csv_path: str) -> pd.DataFrame: df = pd.read_csv(csv_path) - unique_audios = df['audio'].unique() + unique_audios = df["audio"].unique() all_results = [] for audio in unique_audios: - audio_df = df[df['audio'] == audio].copy() + audio_df = df[df["audio"] == audio].copy() if audio_df.empty: continue - min_start = int(np.floor(audio_df['start(s)'].min())) - max_end = int(np.ceil (audio_df['end(s)'].max())) + min_start = int(np.floor(audio_df["start(s)"].min())) + max_end = int(np.ceil(audio_df["end(s)"].max())) for second in range(min_start, max_end): overlapping = audio_df[ - ((audio_df['start(s)'] <= second) & (audio_df['end(s)'] > second)) | - ((audio_df['start(s)'] < second + 1) & (audio_df['end(s)'] >= second + 1)) + ((audio_df["start(s)"] <= second) & (audio_df["end(s)"] > second)) + | ((audio_df["start(s)"] < second + 1) & (audio_df["end(s)"] >= second + 1)) ] if len(overlapping) == 0: continue weights = [] for _, row in overlapping.iterrows(): - overlap_start = max(row['start(s)'], second) - overlap_end = min(row['end(s)'], second + 1) + overlap_start = max(row["start(s)"], second) + overlap_end = min(row["end(s)"], second + 1) overlap_duration = max(0, overlap_end - overlap_start) weights.append(overlap_duration) weights = np.array(weights) if weights.sum() == 0: continue weights = weights / weights.sum() - avg_pred = np.average(overlapping['prediction'], weights=weights) - avg_prob = np.average(overlapping['probability'], weights=weights) - avg_conf = np.average(overlapping['confidence'], weights=weights) - all_results.append({ - 'audio': audio, - 'second': second, - 'count_overlaps': len(overlapping), - 'prediction': 1 if avg_pred >= 0.5 else 0, - 'avg_prediction': avg_pred, - 'avg_probability': avg_prob, - 'avg_confidence': avg_conf, - }) - results_df = pd.DataFrame(all_results).sort_values(['audio', 'second']).reset_index(drop=True) - out_path = os.path.join(os.path.dirname(csv_path), 'per_second_results.csv') + avg_pred = np.average(overlapping["prediction"], weights=weights) + avg_prob = np.average(overlapping["probability"], weights=weights) + avg_conf = np.average(overlapping["confidence"], weights=weights) + all_results.append( + { + "audio": audio, + "second": second, + "count_overlaps": len(overlapping), + "prediction": 1 if avg_pred >= 0.5 else 0, + "avg_prediction": avg_pred, + "avg_probability": avg_prob, + "avg_confidence": avg_conf, + } + ) + results_df = pd.DataFrame(all_results).sort_values(["audio", "second"]).reset_index(drop=True) + out_path = os.path.join(os.path.dirname(csv_path), "per_second_results.csv") results_df.to_csv(out_path, index=False) return results_df + def summarize_audio_level(per_second_csv_path: str, threshold: float) -> pd.DataFrame: - if not os.path.exists(per_second_csv_path): return pd.DataFrame() + if not os.path.exists(per_second_csv_path): + return pd.DataFrame() df = pd.read_csv(per_second_csv_path) - if df.empty: return pd.DataFrame() + if df.empty: + return pd.DataFrame() out_rows = [] for audio, g in df.groupby("audio"): g = g.sort_values("second") @@ -435,7 +540,7 @@ def summarize_audio_level(per_second_csv_path: str, threshold: float) -> pd.Data pos = g[g["avg_probability"] >= threshold] if not pos.empty: start = float(pos["second"].min()) - end = float(pos["second"].max() + 1) + end = float(pos["second"].max() + 1) detected = True else: start, end, detected = None, None, False @@ -445,11 +550,13 @@ def summarize_audio_level(per_second_csv_path: str, threshold: float) -> pd.Data summary.to_csv(out_path, index=False) return summary + def cleanup_spectrograms(path: str): if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) -# CSV logging (detections only) + +# ----------------- CSV logging (detections only) ----------------- def write_to_csv(audio_name, detection, confidence, date): lock = FileLock(CSV_PATH + ".lock", timeout=5) try: @@ -463,6 +570,7 @@ def write_to_csv(audio_name, detection, confidence, date): except Exception as e: logger.error(f"Failed to write CSV '{CSV_PATH}': {e}") + def log_audio_detection(audio_path: str, prob: float): audio_name = os.path.basename(audio_path) detection = True @@ -470,27 +578,25 @@ def log_audio_detection(audio_path: str, prob: float): date_str = datetime.utcnow().isoformat(timespec="seconds") + "Z" write_to_csv(audio_name, detection, confidence, date_str) -# End-to-end classify one file + +# ----------------- End-to-end classify one file ----------------- def classify_audio_file(audio_path: str) -> Tuple[bool, float]: """ Full pipeline on a single recorded file in PROCESSING_DIR: - window -> mel (npy) -> Triton -> per-second -> audio summary. + window -> mel (npy) -> ONNX -> per-second -> audio summary. Returns (detected, max_prob). """ base = os.path.splitext(os.path.basename(audio_path))[0] - # Outputs live under PROCESSING_DIR/inference_output/ output_dir = os.path.join(PROCESSING_DIR, "inference_output") spectrograms_path = os.path.abspath(os.path.join(output_dir, "spectrograms")) os.makedirs(spectrograms_path, exist_ok=True) - # Build windows for just this audio_path windows = build_windows_for_file(audio_path, WINDOW_SIZE_SEC, OVERLAP_SEC, SAMPLE_RATE) if len(windows) == 0: logger.warning(f"No windows produced for {audio_path}") return False, 0.0 - # Compute specs compute_all_mel_spectrograms_gpu( windows=windows, sample_rate=SAMPLE_RATE, @@ -500,55 +606,56 @@ def classify_audio_file(audio_path: str) -> Tuple[bool, float]: top_db=TOP_DB, spectrograms_path=spectrograms_path, save_npy=True, - fill_noise=False, # no noise fill needed at inference + fill_noise=False, noise_db_mean=None, noise_db_std=3.0, random_state=42, storage_dtype="float32", ) - # Dataset / loader df = pd.DataFrame(windows) - df['spec_name'] = df.apply( - lambda row: f"{os.path.basename(row['sound_path']).split('.')[0]}_{row['start']}_{row['end']}.npy", - axis=1 + df["spec_name"] = df.apply( + lambda row: f"{os.path.basename(row['sound_path']).split('.')[0]}_{row['start']}_{row['end']}.npy", axis=1 ) n_frames = int(np.ceil((WINDOW_SIZE_SEC * SAMPLE_RATE - N_FFT) / HOP_LENGTH)) + 1 target_size = (N_MELS, n_frames) - dataset = BioacousticsInferenceDataset( - dataframe=df, root=spectrograms_path, x_col="spec_name", target_size=target_size - ) - dataloader = DataLoader( - dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS - ) + dataset = BioacousticsInferenceDataset(dataframe=df, root=spectrograms_path, x_col="spec_name", target_size=target_size) + dataloader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS) - # Triton inference (batched) try: - audios, starts, ends, predictions, probabilities = run_inference_triton(dataloader, SAMPLE_RATE) + audios, starts, ends, predictions, probabilities = run_inference_onnx(dataloader, SAMPLE_RATE) except Exception as e: - logger.error(f"Triton inference failed for {audio_path}: {e}") + logger.error(f"ONNX inference failed for {audio_path}: {e}") + return False, 0.0 + + if len(audios) == 0: + logger.info("No inference results (model missing/unavailable); treating as no detection.") + if DELETE_SPECTROGRAMS_AFTER: + del dataloader, dataset + if torch.cuda.is_available(): + torch.cuda.empty_cache() + cleanup_spectrograms(spectrograms_path) return False, 0.0 - # Save window-level results os.makedirs(output_dir, exist_ok=True) results_path = os.path.join(output_dir, "inference_results.csv") - results_df = pd.DataFrame({ - 'audio': audios, - 'start(s)': starts, - 'end(s)': ends, - 'prediction': predictions, - 'probability': probabilities, - 'confidence': np.abs(probabilities - 0.5) * 2, - }).sort_values('confidence', ascending=False) + results_df = pd.DataFrame( + { + "audio": audios, + "start(s)": starts, + "end(s)": ends, + "prediction": predictions, + "probability": probabilities, + "confidence": np.abs(probabilities - 0.5) * 2, + } + ).sort_values("confidence", ascending=False) results_df.to_csv(results_path, index=False) - # Per-second & summary process_inference_results_per_second(results_path) per_second_csv = os.path.join(output_dir, "per_second_results.csv") summary = summarize_audio_level(per_second_csv, threshold=SUMMARY_THRESHOLD) - # Extract decision for this file detected, max_prob = False, 0.0 if not summary.empty: row = summary[summary["audio"] == base] @@ -556,7 +663,6 @@ def classify_audio_file(audio_path: str) -> Tuple[bool, float]: detected = bool(row["detected"].iloc[0]) max_prob = float(row["max_prob"].iloc[0]) - # Cleanup specs to save disk if DELETE_SPECTROGRAMS_AFTER: del dataloader, dataset if torch.cuda.is_available(): @@ -565,7 +671,8 @@ def classify_audio_file(audio_path: str) -> Tuple[bool, float]: return detected, max_prob -# Recording helpers + +# ----------------- Recording helpers ----------------- def _wait_for_stable_file(path: str, tries: int = 4, sleep_s: float = 0.2) -> bool: prev = -1 for _ in range(tries): @@ -579,26 +686,40 @@ def _wait_for_stable_file(path: str, tries: int = 4, sleep_s: float = 0.2) -> bo time.sleep(sleep_s) return os.path.exists(path) and os.path.getsize(path) > 0 + def _processing_path_for_timestamp(ts: str) -> str: return os.path.join(PROCESSING_DIR, f"{ts}.wav") + def _final_recordings_path_for(ts: str) -> str: return os.path.join(RECORDINGS_DIR, f"{ts}.wav") + def _record_to_processing(duration_sec: int) -> str: ts = datetime.now().strftime("%Y%m%d_%H%M%S") out = _processing_path_for_timestamp(ts) cmd = [ - "arecord", "-D", DEVICE, - "-t", "wav", "-c", "1", - "-f", "S16_LE", "-r", str(CAPTURE_SR), - "-d", str(duration_sec), out, + "arecord", + "-D", + DEVICE, + "-t", + "wav", + "-c", + "1", + "-f", + "S16_LE", + "-r", + str(CAPTURE_SR), + "-d", + str(duration_sec), + out, ] subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if not _wait_for_stable_file(out): raise RuntimeError(f"Recorded file missing/empty: {out}") return out + def _keep_move_to_recordings(processing_file: str) -> str: ts = os.path.splitext(os.path.basename(processing_file))[0] dest = _final_recordings_path_for(ts) @@ -610,12 +731,27 @@ def _keep_move_to_recordings(processing_file: str) -> str: logger.error(f"Failed to move kept file to recordings: {e}") return processing_file -# Monitoring & Recording + +# ----------------- Monitoring & Recording ----------------- def monitor_audio(): fd, temp_file = tempfile.mkstemp(prefix="monitor_", suffix=".wav") os.close(fd) - command = ["arecord", "-D", DEVICE, "-t", "wav", "-c", "1", "-f", "S16_LE", - "-r", str(CAPTURE_SR), "-d", str(MONITOR_DURATION), temp_file] + command = [ + "arecord", + "-D", + DEVICE, + "-t", + "wav", + "-c", + "1", + "-f", + "S16_LE", + "-r", + str(CAPTURE_SR), + "-d", + str(MONITOR_DURATION), + temp_file, + ] try: subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if not _wait_for_stable_file(temp_file): @@ -627,8 +763,11 @@ def monitor_audio(): logger.error(f"Monitoring failed: {e}") return None finally: - try: os.remove(temp_file) - except Exception: pass + try: + os.remove(temp_file) + except Exception: + pass + def record_triggered_audio(): try: @@ -649,7 +788,7 @@ def record_triggered_audio(): except Exception as e: logger.error(f"Unexpected error during triggered recording: {e}") -# Monitoring Loop + def audio_monitoring_loop(): logger.info("Audio monitoring thread started.") while True: @@ -691,14 +830,14 @@ def audio_monitoring_loop(): logger.info(f"Next scheduled record in {to_sleep}s") time.sleep(to_sleep) -# Settings Fetch Thread + def settings_fetching_loop(unique_id): logger.info("Settings fetching thread started.") while True: fetch_settings(unique_id) time.sleep(120) -# Main + def main(): logger.info("Audio recording script started.") os.makedirs(RECORDINGS_DIR, exist_ok=True) @@ -718,5 +857,6 @@ def main(): except Exception as e: logger.critical(f"Unexpected error in main thread: {e}") + if __name__ == "__main__": main() diff --git a/sparrow/images/Test Cam New_00_20260202112950.txt b/sparrow/images/Test Cam New_00_20260202112950.txt new file mode 100644 index 0000000..fecdd54 --- /dev/null +++ b/sparrow/images/Test Cam New_00_20260202112950.txt @@ -0,0 +1 @@ +This is a test file sent by user camera according to photo configure. \ No newline at end of file diff --git a/sparrow/inference.py b/sparrow/inference.py index 188f4c9..710856c 100644 --- a/sparrow/inference.py +++ b/sparrow/inference.py @@ -1,16 +1,22 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ -This script uses Triton Inference Server to perform object detection using the MegaDetectorV6 model, -and then for each "animal" detection, it crops the bounding box and sends it to a classification -model (e.g., AI4GAmazonClassification) for species classification. - -Results are: -- Logged to CSV -- For JPEG outputs, all bounding boxes (with labels & scores) are stored as JSON in EXIF - UserComment so server-side can turn overlays on/off later. - -By default, the saved image pixels are on (boxes drawn). You can disable drawing with: - DRAW_BOXES=flase +ONNXRuntime-only inference pipeline (Pi-friendly). + +- Runs MegaDetectorV6 (detection) using ONNXRuntime +- For each "animal" detection, crops and runs a classification ONNX model +- Logs to CSV +- Saves JPEG outputs with detection metadata stored as JSON in EXIF UserComment +- Optionally draws boxes on output images (DRAW_BOXES=true/false) + +Required env: + LOCAL_MODELS_DIR -> root folder containing ONNX models: + /megadetectorv6/1/model.onnx + //1/model.onnx + +Optional env: + ONLY_SAVE_ANIMALS=true/false + DRAW_BOXES=true/false + SERVER_BASE_URL (defaults to https://server.sparrowstudio.azure.com/v1) """ import os @@ -23,15 +29,28 @@ from PIL import Image, ImageFile, ImageDraw, ImageFont import numpy as np -import tritonclient.http as httpclient import torch import torchvision.transforms as T import torch.nn.functional as F import requests from filelock import FileLock +import piexif # EXIF metadata + from utils.sparrow_id import get_hardware_id from utils.detection_utils import non_max_suppression, scale_boxes -import piexif # EXIF metadata + +# ----------------- ONNXRuntime only ----------------- +try: + import onnxruntime as ort # type: ignore + + HAVE_ORT = True +except ImportError: + HAVE_ORT = False + ort = None + +LOCAL_MODELS_DIR = os.getenv("LOCAL_MODELS_DIR", "").strip() or None + +# ---------------------------------------------------- # Setup Logging & Folders LOGS_DIR = "/app/logs" @@ -39,11 +58,11 @@ logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ logging.FileHandler(os.path.join(LOGS_DIR, "inference.log")), - logging.StreamHandler() - ] + logging.StreamHandler(), + ], ) model_logger = logging.getLogger("model_settings") log = logging.getLogger("inference") @@ -56,13 +75,14 @@ MODEL_CONFIG_FILE = os.path.join(CONFIG_DIR, "model_settings.json") MODEL_CONFIG_LOCK = f"{MODEL_CONFIG_FILE}.lock" -SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrow-earth.com").rstrip("/") +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrowstudio.azure.com/v1").rstrip("/") MODEL_ENDPOINT = f"{SERVER_BASE_URL}/model_settings" model_logger.info(f"Model settings endpoint: {MODEL_ENDPOINT}") AUTH_KEY_PATH = "/app/config/access_key.txt" DEFAULT_MODEL_CONFIG = { "selected_model": "AI4GAmazonClassification", + # NOTE: keeping the original key name "lables" for backward compatibility "lables": { "0": "Dasyprocta", "1": "Bos", @@ -99,11 +119,11 @@ "32": "Furnarius", "33": "Didelphis", "34": "Sylvilagus", - "35": "Unknown" + "35": "Unknown", }, "classification_enabled": True, "keep_blanks": False, - "detection_threshold": 0.4 + "detection_threshold": 0.4, } os.makedirs(CONFIG_DIR, exist_ok=True) @@ -137,12 +157,13 @@ def save_model_config(config): def fetch_model_settings(unique_id, auth_key): - """ - Fetch updated model settings from the server. - If different from local, update local file. - """ + """Fetch updated model settings from server; update local file if changed.""" payload = {"unique_id": unique_id, "auth_key": auth_key} - headers = {"Content-Type": "application/json"} + headers = { + "Content-Type": "application/json", + "X-API-Key": auth_key, + "X-Unit-ID": unique_id, + } try: response = requests.post(MODEL_ENDPOINT, json=payload, headers=headers, timeout=10) if response.status_code == 200: @@ -154,7 +175,9 @@ def fetch_model_settings(unique_id, auth_key): else: model_logger.info("Model settings unchanged.") else: - model_logger.warning(f"Model settings fetch failed: {response.status_code} - {response.text}") + model_logger.warning( + f"Model settings fetch failed: {response.status_code} - {response.text}" + ) except Exception as e: model_logger.warning(f"Could not fetch model settings: {e}") @@ -168,27 +191,24 @@ def model_settings_fetch_loop(unique_id, auth_key): def get_current_model_name(): - """Get current classification model name.""" return load_model_config().get("selected_model", "AI4GAmazonClassification") def get_current_labels(): - """Get current label dictionary.""" - return load_model_config().get("lables", DEFAULT_MODEL_CONFIG["lables"]) + cfg = load_model_config() + # accept either "lables" (legacy) or "labels" (if server fixed spelling) + return cfg.get("lables") or cfg.get("labels") or DEFAULT_MODEL_CONFIG["lables"] def is_classification_enabled(): - """Whether classification is enabled.""" return load_model_config().get("classification_enabled", True) def is_keep_blanks_enabled(): - """Whether blank images should be kept.""" return load_model_config().get("keep_blanks", False) def get_detection_threshold(): - """Get detection confidence threshold.""" cfg = load_model_config() return cfg.get("detection_threshold", DEFAULT_MODEL_CONFIG["detection_threshold"]) @@ -198,7 +218,6 @@ def get_detection_threshold(): def load_font(): - """Return a Pillow built-in bitmap font.""" return ImageFont.load_default() @@ -222,14 +241,18 @@ def letterbox(im, new_shape=(640, 640), auto=False, scaleFill=False, scaleup=Tru r = (new_shape[1] / shape[1], new_shape[0] / shape[0]) dw /= 2 dh /= 2 - # Resize if shape[::-1] != new_unpad: - resize_transform = T.Resize(new_unpad[::-1], interpolation=T.InterpolationMode.BILINEAR, antialias=False) + resize_transform = T.Resize( + new_unpad[::-1], + interpolation=T.InterpolationMode.BILINEAR, + antialias=False, + ) im = resize_transform(im) - # Pad padding = ( - int(round(dw - 0.1)), int(round(dw + 0.1)), - int(round(dh + 0.1)), int(round(dh - 0.1)) + int(round(dw - 0.1)), + int(round(dw + 0.1)), + int(round(dh + 0.1)), + int(round(dh - 0.1)), ) im = F.pad(im * 255.0, padding, value=114) / 255.0 return im @@ -241,10 +264,7 @@ def letterbox(im, new_shape=(640, 640), auto=False, scaleFill=False, scaleup=Tru def preprocess_classification(img): - """ - Preprocess a PIL image for classification: - Resizes to 224x224, converts to tensor -> numpy [1,3,224,224]. - """ + """Preprocess PIL image for classification -> numpy [1,3,224,224] FP32.""" img = img.resize((224, 224)) img_tensor = T.ToTensor()(img) img_np = img_tensor.numpy() @@ -252,15 +272,84 @@ def preprocess_classification(img): return img_np -# Triton / IO Setup -TRITON_URL = (os.getenv("TRITON_SERVER_URL") or os.getenv("TRITON_URL", "http://triton:8000")).strip().rstrip("/") -if TRITON_URL.startswith(("http://", "https://")): - TRITON_URL = TRITON_URL.split("://", 1)[1] +# --------- ONNX inference wrappers (only) ---------- +md_ort_session = None +clf_ort_sessions = {} # model_name -> session + + +def _ensure_ort_ready() -> bool: + if not HAVE_ORT or ort is None: + log.error("onnxruntime is not installed. Cannot run inference.") + return False + if not LOCAL_MODELS_DIR: + log.error("LOCAL_MODELS_DIR is not set. Cannot locate ONNX models.") + return False + return True + + +def _get_megadetector_onnx_session(): + global md_ort_session + if md_ort_session is not None: + return md_ort_session + if not _ensure_ort_ready(): + return None + md_path = os.path.join(LOCAL_MODELS_DIR, "megadetectorv6", "1", "model.onnx") + if not os.path.isfile(md_path): + log.error(f"MegaDetector ONNX model not found at: {md_path}") + return None + log.info(f"Loading MegaDetector ONNX model from {md_path}") + md_ort_session = ort.InferenceSession(md_path, providers=["CPUExecutionProvider"]) + return md_ort_session + + +def _get_classifier_onnx_session(model_name: str): + global clf_ort_sessions + if model_name in clf_ort_sessions: + return clf_ort_sessions[model_name] + if not _ensure_ort_ready(): + return None + clf_path = os.path.join(LOCAL_MODELS_DIR, model_name, "1", "model.onnx") + if not os.path.isfile(clf_path): + log.error(f"Classifier ONNX model not found at: {clf_path}") + return None + log.info(f"Loading classifier ONNX model '{model_name}' from {clf_path}") + sess = ort.InferenceSession(clf_path, providers=["CPUExecutionProvider"]) + clf_ort_sessions[model_name] = sess + return sess + + +def run_megadetector(image_np: np.ndarray): + """Run MegaDetector with ONNXRuntime. Returns output ndarray or None.""" + sess = _get_megadetector_onnx_session() + if sess is None: + return None + try: + input_name = sess.get_inputs()[0].name + outputs = sess.run(None, {input_name: image_np.astype(np.float32)}) + return outputs[0] + except Exception as e: + log.error(f"ONNX MegaDetector inference failed: {e}") + return None -client = httpclient.InferenceServerClient(url=TRITON_URL, network_timeout=600.0) -# Megadetector model -megadetector_model_name = "megadetectorv6" +def run_classifier(cropped_np: np.ndarray, model_name: str): + """Run classifier with ONNXRuntime. Returns 1D probs or None.""" + sess = _get_classifier_onnx_session(model_name) + if sess is None: + return None + try: + input_name = sess.get_inputs()[0].name + outputs = sess.run(None, {input_name: cropped_np.astype(np.float32)}) + logits = outputs[0][0].astype(np.float32, copy=False) + exp_scores = np.exp(logits - np.max(logits)) + probs = exp_scores / np.sum(exp_scores) + return probs + except Exception as e: + log.error(f"ONNX classifier '{model_name}' inference failed: {e}") + return None + + +# ------------------------------------------------------ # Input and output directories input_dir = "/app/images/" @@ -268,37 +357,21 @@ def preprocess_classification(img): os.makedirs(output_dir, exist_ok=True) # CSV for logging detections -csv_file = '/app/static/data/detections.csv' +csv_file = "/app/static/data/detections.csv" os.makedirs(os.path.dirname(csv_file), exist_ok=True) def write_to_csv(image_name, detection, confidence, date): - """Append detection results to CSV.""" file_exists = os.path.isfile(csv_file) - with open(csv_file, mode='a', newline='') as file: + with open(csv_file, mode="a", newline="") as file: writer = csv.writer(file) if not file_exists: - writer.writerow(['Image Name', 'Detection', 'Confidence Score', 'Date']) + writer.writerow(["Image Name", "Detection", "Confidence Score", "Date"]) writer.writerow([image_name, detection, confidence, date]) def save_jpeg_with_boxes(img, boxes_meta, out_path): - """ - Save a JPEG with bounding boxes stored as JSON in EXIF UserComment. - - boxes_meta: list of dicts, each like: - { - "x1": float (normalized 0-1), - "y1": float, - "x2": float, - "y2": float, - "label": str, - "score": float, - "class_id": int, - "source": str, - "model": str or None - } - """ + """Save JPEG with boxes metadata stored as JSON in EXIF UserComment.""" exif_bytes_in = img.info.get("exif", b"") if exif_bytes_in: try: @@ -309,13 +382,27 @@ def save_jpeg_with_boxes(img, boxes_meta, out_path): exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} payload = json.dumps(boxes_meta).encode("utf-8") - # EXIF UserComment should start with an encoding prefix exif_dict["Exif"][piexif.ExifIFD.UserComment] = b"ASCII\0\0\0" + payload - exif_bytes_out = piexif.dump(exif_dict) img.save(out_path, format="JPEG", exif=exif_bytes_out) +def save_sidecar_metadata(out_path, image_name, captured_at_iso, img_w, img_h, pixel_boxes): + """Write .json (sibling of the JPEG) with server-ready metadata.""" + sidecar = { + "image_name": image_name, + "captured_at": captured_at_iso, + "image_width": img_w, + "image_height": img_h, + "detections": pixel_boxes, + } + side_path = os.path.splitext(out_path)[0] + ".json" + tmp = side_path + ".tmp" + with open(tmp, "w") as f: + json.dump(sidecar, f) + os.replace(tmp, side_path) + + # Background Settings Fetch try: with open(AUTH_KEY_PATH, "r") as f: @@ -335,211 +422,253 @@ def save_jpeg_with_boxes(img, boxes_meta, out_path): model_thread = threading.Thread( target=model_settings_fetch_loop, args=(UNIQUE_ID, AUTH_KEY), - daemon=True + daemon=True, ) model_thread.start() +log.info( + f"ONNXRuntime-only mode. HAVE_ORT={HAVE_ORT}, LOCAL_MODELS_DIR={LOCAL_MODELS_DIR}, " + f"ONLY_SAVE_ANIMALS={ONLY_SAVE_ANIMALS}, DRAW_BOXES={DRAW_BOXES}" +) + # Main Processing Loop while True: - for image_name in os.listdir(input_dir): - if image_name.lower().endswith(('.png', '.jpg', '.jpeg')): - image_path = os.path.join(input_dir, image_name) - print(f"Opening {image_path}") - attempt = 0 - while attempt < 3: - if attempt != 0: - time.sleep(5) - print(f"Attempt {attempt}") - try: - image = Image.open(image_path).convert("RGB") - break - except Exception as e: - print(e) - attempt += 1 - if attempt == 3: + try: + names = os.listdir(input_dir) + except Exception as e: + log.error(f"Failed to list input_dir={input_dir}: {e}") + time.sleep(5) + continue + + for image_name in names: + if not image_name.lower().endswith((".png", ".jpg", ".jpeg")): + continue + + image_path = os.path.join(input_dir, image_name) + log.info(f"Processing: {image_path}") + + # Try opening a few times (handles files still being written) + attempt = 0 + image = None + while attempt < 3: + if attempt != 0: + time.sleep(5) + try: + image = Image.open(image_path).convert("RGB") + break + except Exception as e: + log.warning(f"Open failed (attempt {attempt+1}/3) for {image_path}: {e}") + attempt += 1 + + if image is None: + try: os.remove(image_path) - print(f"Removed {image_path} without processing.") - continue - - # Prepare image for MegaDetector - img_lb = letterbox(image, new_shape=(640, 640), auto=False, stride=32) - image_np = img_lb.numpy() - image_np = np.expand_dims(image_np, axis=0) - - md_inputs = [httpclient.InferInput("images", image_np.shape, "FP32")] - md_inputs[0].set_data_from_numpy(image_np) - md_outputs = [httpclient.InferRequestedOutput("output0")] - - md_results = client.infer(megadetector_model_name, md_inputs, outputs=md_outputs) - output_data = md_results.as_numpy("output0") - print(f"MegaDetector output shape: {output_data.shape}") + log.warning(f"Removed {image_path} without processing (could not open).") + except Exception as e: + log.error(f"Failed to remove {image_path}: {e}") + continue + + # Prepare image for MegaDetector + img_lb = letterbox(image, new_shape=(640, 640), auto=False, stride=32) + image_np = img_lb.numpy() + image_np = np.expand_dims(image_np, axis=0).astype(np.float32) + + # MegaDetector inference + output_data = run_megadetector(image_np) + if output_data is None: + log.error(f"MegaDetector inference failed for {image_name}; treating as blank.") + try: + date_str = image_name.split("_")[-1].split(".")[0][:14] + date = datetime.strptime(date_str, "%Y%m%d%H%M%S") + except Exception: + date = datetime.utcnow() + + if is_keep_blanks_enabled(): + write_to_csv(image_name, "blank", 0.0, date) + image.save(os.path.join(output_dir, image_name)) + log.info(f"Saved blank image to {output_dir}") + + try: + os.remove(image_path) + except Exception as e: + log.error(f"Failed to remove {image_path}: {e}") + continue - # Extract datetime from filename - date_str = image_name.split('_')[-1].split('.')[0][:14] - date = datetime.strptime(date_str, '%Y%m%d%H%M%S') + # Extract datetime from filename + try: + date_str = image_name.split("_")[-1].split(".")[0][:14] + date = datetime.strptime(date_str, "%Y%m%d%H%M%S") + except Exception: + date = datetime.utcnow() - # Non-max suppression - conf_thres = get_detection_threshold() + # Non-max suppression + conf_thres = get_detection_threshold() + try: pred = non_max_suppression( torch.tensor(output_data), conf_thres=conf_thres, iou_thres=0.5, - agnostic=False + agnostic=False, )[0].numpy() - print(f"MegaDetector predictions: {pred}") - - # Handle blank - if pred.size == 0: - if is_keep_blanks_enabled(): - write_to_csv(image_name, "blank", 1.0, date) - os.makedirs(output_dir, exist_ok=True) - image.save(os.path.join(output_dir, image_name)) - print(f"Saved blank image to {output_dir}") + except Exception as e: + log.error(f"NMS failed for {image_name}: {e}") + pred = np.array([]) + + # Handle blank + if pred.size == 0: + if is_keep_blanks_enabled(): + write_to_csv(image_name, "blank", 1.0, date) + image.save(os.path.join(output_dir, image_name)) + log.info(f"Saved blank image to {output_dir}") + try: os.remove(image_path) - print(f"Removed source file {image_path} (blank)") + except Exception as e: + log.error(f"Failed to remove {image_path}: {e}") + continue + + # Scale boxes back to original image size + pred[:, :4] = scale_boxes([640, 640], pred[:, :4], np.array(image).shape) + + xyxy = pred[:, :4] + md_confidence = pred[:, 4] + md_class_id = pred[:, 5].astype(int) + + font = load_font() + drew_any = False + skipped_count = 0 + + boxes_meta = [] + pixel_boxes = [] + img_w, img_h = image.size + + annotated_img = image.copy() if DRAW_BOXES else image + draw = ImageDraw.Draw(annotated_img) if DRAW_BOXES else None + + for i in range(len(pred)): + cls_id = int(md_class_id[i]) + + if ONLY_SAVE_ANIMALS and cls_id in (1, 2): + skipped_count += 1 continue - if len(pred) > 0: - # Scale boxes back to original image size - pred[:, :4] = scale_boxes([640, 640], pred[:, :4], np.array(image).shape) - xyxy = pred[:, :4] - md_confidence = pred[:, 4] - md_class_id = pred[:, 5].astype(int) - - font = load_font() - - drew_any = False # we had at least one kept detection - skipped_count = 0 # how many non-animal detections we skip - - # Metadata for EXIF (one dict per detection) - boxes_meta = [] - img_w, img_h = image.size - - # Only create drawing context if we actually want boxes rendered - annotated_img = image.copy() if DRAW_BOXES else image - draw = ImageDraw.Draw(annotated_img) if DRAW_BOXES else None - - for i in range(len(pred)): - cls_id = md_class_id[i] - - # Skip non-animals (person=1, vehicle=2) if ONLY_SAVE_ANIMALS is enabled - if ONLY_SAVE_ANIMALS and cls_id in (1, 2): - try: - x1_s, y1_s, x2_s, y2_s = [float(v) for v in xyxy[i]] - except Exception: - x1_s = y1_s = x2_s = y2_s = -1.0 - label_skipped = "person" if cls_id == 1 else "vehicle" - conf_s = float(md_confidence[i]) - log.info( - f"Skipping {label_skipped} (conf={conf_s:.2f}) due to ONLY_SAVE_ANIMALS; " - f"image={image_name}, box=({x1_s:.1f},{y1_s:.1f},{x2_s:.1f},{y2_s:.1f})" - ) - skipped_count += 1 - continue - - md_label = class_name_to_id[cls_id] - det_conf = md_confidence[i] - x1, y1, x2, y2 = xyxy[i] - - # Only run classification if it's an "animal" AND classification is enabled - if cls_id == 0 and is_classification_enabled(): - cropped = image.crop((x1, y1, x2, y2)) - cropped_np = preprocess_classification(cropped) - - clf_inputs = [httpclient.InferInput("input", cropped_np.shape, "FP32")] - clf_inputs[0].set_data_from_numpy(cropped_np) - clf_outputs = [httpclient.InferRequestedOutput("output")] - - current_model_name = get_current_model_name() - clf_results = client.infer(current_model_name, clf_inputs, outputs=clf_outputs) - - clf_output = clf_results.as_numpy("output") # [1, 36] - exp_scores = np.exp(clf_output[0]) - probs = exp_scores / np.sum(exp_scores) - pred_class = int(np.argmax(probs)) - clf_conf = float(np.max(probs)) - - labels_dict = get_current_labels() - detected_class = labels_dict.get(str(pred_class), "Unknown") - if clf_conf < 0.8: - detected_class = "Unknown" - - write_to_csv(image_name, detected_class, clf_conf, date) - label = f"{detected_class} {clf_conf:.2f}" - - stored_label = detected_class - stored_conf = clf_conf - stored_model = current_model_name - else: - # For person/vehicle, or if classification disabled, use MD label only - write_to_csv(image_name, md_label, det_conf, date) - label = f"{md_label} {det_conf:.2f}" - - stored_label = md_label - stored_conf = float(det_conf) - stored_model = None - - # Optionally draw bounding box and label - if DRAW_BOXES and draw is not None: - draw.rectangle(xyxy[i], outline=colors[cls_id], width=2) - text_bbox = draw.textbbox((xyxy[i][0], xyxy[i][1] - 20), label, font=font) - draw.rectangle( - [text_bbox[0], text_bbox[1] - 2, text_bbox[2] + 2, text_bbox[3] + 2], - fill=colors[cls_id] - ) - draw.text((xyxy[i][0] + 2, xyxy[i][1] - 20), label, font=font, fill='white') - - drew_any = True # we have at least one kept detection - - # Store normalized coordinates + label in metadata list - norm_x1 = float(x1) / float(img_w) - norm_y1 = float(y1) / float(img_h) - norm_x2 = float(x2) / float(img_w) - norm_y2 = float(y2) / float(img_h) - - boxes_meta.append( - { - "x1": norm_x1, - "y1": norm_y1, - "x2": norm_x2, - "y2": norm_y2, - "label": stored_label, - "score": float(stored_conf), - "class_id": int(cls_id), - "source": "megadetectorv6", - "model": stored_model, - } - ) - - # Per-image summary for skipped detections - if ONLY_SAVE_ANIMALS and skipped_count: - log.info(f"{image_name}: skipped {skipped_count} non-animal detection(s) due to ONLY_SAVE_ANIMALS") - - # If we filtered out everything (e.g., only people/vehicles), treat as blank - if not drew_any: - if is_keep_blanks_enabled(): - write_to_csv(image_name, "blank", 1.0, date) - os.makedirs(output_dir, exist_ok=True) - image.save(os.path.join(output_dir, image_name)) - print(f"Saved blank image to {output_dir}") - os.remove(image_path) - print(f"Removed source file {image_path} (all detections filtered)") - continue - - # Save image: if DRAW_BOXES=True, save annotated image; otherwise save original. - out_path = os.path.join(output_dir, image_name) - img_to_save = annotated_img if DRAW_BOXES else image - - if image_name.lower().endswith((".jpg", ".jpeg")): - save_jpeg_with_boxes(img_to_save, boxes_meta, out_path) + md_label = class_name_to_id.get(cls_id, "unknown") + det_conf = float(md_confidence[i]) + x1, y1, x2, y2 = [float(v) for v in xyxy[i]] + + # Run classification only for animals if enabled + if cls_id == 0 and is_classification_enabled(): + cropped = image.crop((x1, y1, x2, y2)) + cropped_np = preprocess_classification(cropped) + + current_model_name = get_current_model_name() + probs = run_classifier(cropped_np, current_model_name) + + if probs is None or probs.size == 0: + detected_class = md_label + clf_conf = det_conf + stored_model = None else: - img_to_save.save(out_path) + pred_class = int(np.argmax(probs)) + clf_conf = float(np.max(probs)) + labels_dict = get_current_labels() + detected_class = labels_dict.get(str(pred_class), "Unknown") + if clf_conf < 0.8: + detected_class = "Unknown" + stored_model = current_model_name + + write_to_csv(image_name, detected_class, clf_conf, date) + label_text = f"{detected_class} {clf_conf:.2f}" + + stored_label = detected_class + stored_conf = clf_conf + else: + # Person/vehicle, or classification disabled + write_to_csv(image_name, md_label, det_conf, date) + label_text = f"{md_label} {det_conf:.2f}" + + stored_label = md_label + stored_conf = det_conf + stored_model = None + + # Optionally draw + if DRAW_BOXES and draw is not None: + draw.rectangle([x1, y1, x2, y2], outline=colors[cls_id] if cls_id in (0, 1, 2) else "white", width=2) + text_bbox = draw.textbbox((x1, y1 - 20), label_text, font=font) + draw.rectangle( + [text_bbox[0], text_bbox[1] - 2, text_bbox[2] + 2, text_bbox[3] + 2], + fill=colors[cls_id] if cls_id in (0, 1, 2) else "black", + ) + draw.text((x1 + 2, y1 - 20), label_text, font=font, fill="white") + + drew_any = True + + # Store normalized coordinates + label in metadata list + norm_x1 = x1 / float(img_w) + norm_y1 = y1 / float(img_h) + norm_x2 = x2 / float(img_w) + norm_y2 = y2 / float(img_h) + + boxes_meta.append( + { + "x1": float(norm_x1), + "y1": float(norm_y1), + "x2": float(norm_x2), + "y2": float(norm_y2), + "label": stored_label, + "score": float(stored_conf), + "class_id": int(cls_id), + "source": "megadetectorv6", + "model": stored_model, + } + ) + + pixel_boxes.append( + { + "label": stored_label, + "confidence": float(stored_conf), + "bbox": [int(round(x1)), int(round(y1)), int(round(x2)), int(round(y2))], + "class_id": int(cls_id), + } + ) + + if ONLY_SAVE_ANIMALS and skipped_count: + log.info(f"{image_name}: skipped {skipped_count} non-animal detection(s) due to ONLY_SAVE_ANIMALS") + + # If we filtered everything out, treat as blank + if not drew_any: + if is_keep_blanks_enabled(): + write_to_csv(image_name, "blank", 1.0, date) + image.save(os.path.join(output_dir, image_name)) + log.info(f"Saved blank image to {output_dir}") + try: + os.remove(image_path) + except Exception as e: + log.error(f"Failed to remove {image_path}: {e}") + continue - print(f"Saved {out_path}") + # Save output image + out_path = os.path.join(output_dir, image_name) + img_to_save = annotated_img if DRAW_BOXES else image - # Remove original after processing + try: + if image_name.lower().endswith((".jpg", ".jpeg")): + save_jpeg_with_boxes(img_to_save, boxes_meta, out_path) + else: + img_to_save.save(out_path) + except Exception as e: + log.error(f"Failed to save output image {out_path}: {e}") + + try: + captured_at_iso = date.strftime("%Y-%m-%dT%H:%M:%SZ") + save_sidecar_metadata(out_path, image_name, captured_at_iso, img_w, img_h, pixel_boxes) + except Exception as e: + log.error(f"Failed to write sidecar JSON for {out_path}: {e}") + + # Remove original after processing + try: os.remove(image_path) - print(f"Removed {image_path}") + except Exception as e: + log.error(f"Failed to remove {image_path}: {e}") # Check new images every 10s time.sleep(10) diff --git a/sparrow/model_update.py b/sparrow/model_update.py index 7254b54..b45d1cc 100644 --- a/sparrow/model_update.py +++ b/sparrow/model_update.py @@ -1,18 +1,10 @@ - -""" -Triton model sync service: periodically contacts SERVER_BASE_URL for a model manifest, mirrors the -declared repository into LOCAL_MODELS_DIR (atomic downloads, versioned folders), and reconciles the -running Triton server via the Repository API-unloading models that will change, loading/reloading -updated or missing models, and optionally unloading models no longer listed. Autoloads any on-disk models at startup. -""" - import os import sys import time -import requests -from requests.utils import requote_uri import shutil import logging +import requests +from requests.utils import requote_uri from utils.sparrow_id import get_hardware_id # Setup Logging & Folders @@ -27,16 +19,15 @@ log = logging.getLogger(__name__) # Configuration -SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrow-earth.com").rstrip("/") -SERVER_URL = f"{SERVER_BASE_URL}/model_update" # returns {"models":[...], "model_details":{...}} -BASE_FILE_URL = SERVER_BASE_URL # to make relative file URLs absolute - -TRITON_URL = (os.getenv("TRITON_SERVER_URL") or os.getenv("TRITON_URL", "http://triton:8000")).rstrip("/") -UNLOAD_REMOVED = os.getenv("UNLOAD_REMOVED", "true").strip().lower() in ("1", "true", "yes") -STARTUP_AUTOLOAD = os.getenv("STARTUP_AUTOLOAD", "true").strip().lower() in ("1", "true", "yes") +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrowstudio.azure.com/v1").rstrip("/") +SERVER_URL = f"{SERVER_BASE_URL}/model_update" +BASE_FILE_URL = SERVER_BASE_URL HTTP_TIMEOUT = 20 -RETRY_SLEEP = 2.0 +DOWNLOAD_TIMEOUT = 60 +SYNC_INTERVAL = int(os.getenv("SYNC_INTERVAL", "60")) + +REMOVE_MISSING_MODELS = os.getenv("REMOVE_MISSING_MODELS", "true").strip().lower() in ("1", "true", "yes") AUTH_KEY_PATH = "/app/config/access_key.txt" try: @@ -56,115 +47,38 @@ LOCAL_MODELS_DIR = os.environ.get( "LOCAL_MODELS_DIR", - os.path.join(os.path.expanduser("~"), "Desktop", "system", "Models", "tritonserver", "model_repository") + os.path.join(os.path.expanduser("~"), "Desktop", "system", "Models") ) log.info(f"Local models directory: {LOCAL_MODELS_DIR}") os.makedirs(LOCAL_MODELS_DIR, exist_ok=True) -# Triton Helpers (Repository API) -def triton_ready(timeout_sec: int = 120) -> bool: - """Wait until Triton is up. Use live + ready (strict=false) endpoints.""" - live_url = f"{TRITON_URL}/v2/health/live" - ready_url = f"{TRITON_URL}/v2/health/ready?strict=false" - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - rl = requests.get(live_url, timeout=HTTP_TIMEOUT) - rr = requests.get(ready_url, timeout=HTTP_TIMEOUT) - if rl.status_code == 200 and rr.status_code == 200: - return True - except requests.RequestException: - pass - time.sleep(1.0) - return False - -def repo_index() -> list: - """Return Triton repository index: list of {name, version, state, ready}.""" - url = f"{TRITON_URL}/v2/repository/index" - try: - r = requests.post(url, json={}, timeout=HTTP_TIMEOUT) - r.raise_for_status() - return r.json() - except requests.RequestException as e: - log.warning(f"repository/index failed: {e}") - return [] - -def loaded_model_names() -> set: - """Set of models currently loaded (ready=True).""" - idx = repo_index() - names = {item.get("name") for item in idx if item.get("ready") is True} - return {n for n in names if n} - -def triton_load_model(name: str, retries: int = 3) -> bool: - url = f"{TRITON_URL}/v2/repository/models/{name}/load" - for attempt in range(1, retries + 1): - try: - r = requests.post(url, timeout=HTTP_TIMEOUT) - if r.status_code == 200: - log.info(f"Loaded model: {name}") - return True - log.warning(f"Load {name} attempt {attempt} failed: {r.status_code} {r.text}") - except requests.RequestException as e: - log.warning(f"Load {name} attempt {attempt} exception: {e}") - time.sleep(RETRY_SLEEP * attempt) - return False - -def triton_unload_model(name: str, retries: int = 3) -> bool: - url = f"{TRITON_URL}/v2/repository/models/{name}/unload" - for attempt in range(1, retries + 1): - try: - r = requests.post(url, timeout=HTTP_TIMEOUT) - if r.status_code == 200: - log.info(f"Unloaded model: {name}") - return True - log.warning(f"Unload {name} attempt {attempt} failed: {r.status_code} {r.text}") - except requests.RequestException as e: - log.warning(f"Unload {name} attempt {attempt} exception: {e}") - time.sleep(RETRY_SLEEP * attempt) - return False - -def autoload_existing_models(): - """Load any models present on disk (with config.pbtxt) that are not yet loaded.""" - if not triton_ready(): - log.error("Triton not ready; skipping autoload.") - return - current_loaded = loaded_model_names() - for name in os.listdir(LOCAL_MODELS_DIR): - mdir = os.path.join(LOCAL_MODELS_DIR, name) - if not os.path.isdir(mdir): - continue - if not os.path.exists(os.path.join(mdir, "config.pbtxt")): - continue - if name in current_loaded: - log.info(f"Model already loaded: {name}") - continue - triton_load_model(name) -# Download Helper def robust_download_file(file_url: str, local_file_path: str): - """Download to temp then atomic replace; cleanup on failure.""" + """Download to temp file then atomically replace.""" temp_file_path = local_file_path + ".tmp" try: - with requests.get(file_url, stream=True, timeout=60) as r: + os.makedirs(os.path.dirname(local_file_path), exist_ok=True) + with requests.get(file_url, stream=True, timeout=DOWNLOAD_TIMEOUT) as r: r.raise_for_status() - os.makedirs(os.path.dirname(local_file_path), exist_ok=True) with open(temp_file_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): if chunk: f.write(chunk) os.replace(temp_file_path, local_file_path) log.info(f"Downloaded {file_url} -> {local_file_path}") - except Exception as e: + except Exception: if os.path.exists(temp_file_path): try: os.remove(temp_file_path) except Exception: pass - raise e + raise + -# Server model_update Fetch def get_model_update(): - payload = {"auth_key": AUTH_KEY, "unit_id": unit_id, + payload = { + "auth_key": AUTH_KEY, + "unit_id": unit_id, } try: r = requests.post(SERVER_URL, json=payload, timeout=HTTP_TIMEOUT) @@ -176,114 +90,90 @@ def get_model_update(): log.error(f"model_update request failed: {e}") return None -# Sync Logic (Unload/Reload) -def sync_models(model_data: dict): + +def _clean_server_filename(name: str) -> str: + return name.replace("@SynoEAStream", "") + + +def _build_server_versions(server_model_details: dict, model: str) -> dict: """ - Sync local repo with server spec and reconcile Triton load state (explicit mode). - - Unloads a model if its files/versions will change, then reloads after. - - Optionally unloads models that are no longer listed by the server. + Returns: + { + "1": [ {file_info}, {file_info}, ... ], + "2": [ ... ] + } + Only numeric version folders are included. """ - if not triton_ready(): - log.error("Triton not ready; aborting sync.") - return + versions = {} + for sub, files in server_model_details.get(model, {}).items(): + if not sub.isdigit(): + continue + cleaned_files = [] + for file_info in files: + orig = file_info.get("file_name") + if not orig: + continue + if "Zone.Identifier" in orig: + continue + cleaned_files.append(file_info) + versions[sub] = cleaned_files + return versions + +def sync_models(model_data: dict): + """ + Mirror the server model structure locally, without Triton. + Expected local structure: + LOCAL_MODELS_DIR/// + No config.pbtxt is downloaded or managed. + """ server_models = set(model_data.get("models", [])) server_model_details = model_data.get("model_details", {}) - # Current loaded before changes - currently_loaded = loaded_model_names() - - # Helper: plan detection so we unload only if needed - def plan_changes_for_model(model: str): - """ - Return a tuple (will_change: bool, plan: dict) describing what will be created/removed/downloaded. - """ + # Sync models listed by server + for model in server_models: local_model_dir = os.path.join(LOCAL_MODELS_DIR, model) - plan = { - "create_model_dir": not os.path.exists(local_model_dir), - "remove_versions": set(), - "create_versions": set(), - "per_version": {} # version -> dict(remove_files, download_files) - } + os.makedirs(local_model_dir, exist_ok=True) - # Numeric subfolders only - server_subs = { - sub: files - for sub, files in server_model_details.get(model, {}).items() - if sub.isdigit() - } - local_subs = set() + server_versions = _build_server_versions(server_model_details, model) + server_version_names = set(server_versions.keys()) + + local_version_names = set() if os.path.isdir(local_model_dir): - local_subs = { + local_version_names = { d for d in os.listdir(local_model_dir) if os.path.isdir(os.path.join(local_model_dir, d)) and d.isdigit() } - # Identify creates/removals of version folders - server_sub_names = set(server_subs.keys()) - plan["create_versions"] = server_sub_names - local_subs - plan["remove_versions"] = local_subs - server_sub_names - - # For shared versions, compute file diffs - for ver in (server_sub_names & local_subs): - local_vdir = os.path.join(local_model_dir, ver) - local_files = set(os.listdir(local_vdir)) if os.path.isdir(local_vdir) else set() - server_file_names = { - f.get("file_name", "").replace("@SynoEAStream", "") - for f in server_subs.get(ver, []) - if f.get("file_name") and "Zone.Identifier" not in f.get("file_name", "") - } - remove_files = local_files - server_file_names - download_files = server_file_names - local_files - plan["per_version"][ver] = { - "remove_files": remove_files, - "download_files": download_files, - "server_files_info": server_subs.get(ver, []), - } + # Remove version folders not on server + for version in (local_version_names - server_version_names): + version_path = os.path.join(local_model_dir, version) + log.info(f"Removing extra local version: {version_path}") + shutil.rmtree(version_path, ignore_errors=True) - will_change = ( - plan["create_model_dir"] - or bool(plan["create_versions"]) - or bool(plan["remove_versions"]) - or any(d["remove_files"] or d["download_files"] for d in plan["per_version"].values()) - ) - return will_change, plan + # Ensure server versions exist and sync files + for version, file_infos in server_versions.items(): + local_version_dir = os.path.join(local_model_dir, version) + os.makedirs(local_version_dir, exist_ok=True) - # Apply plan per model - for model in server_models: - local_model_dir = os.path.join(LOCAL_MODELS_DIR, model) - will_change, plan = plan_changes_for_model(model) - - # Unload if loaded AND we need to change files/versions - was_loaded = model in currently_loaded - if will_change and was_loaded: - log.info(f"Model '{model}' will change; unloading before update.") - triton_unload_model(model) - - # Ensure model dir - if plan["create_model_dir"]: - os.makedirs(local_model_dir, exist_ok=True) - log.info(f"Created model dir: {local_model_dir}") - - # Remove extra local versions - for ver in plan["remove_versions"]: - path = os.path.join(local_model_dir, ver) - log.info(f"Removing extra local version: {path}") - shutil.rmtree(path, ignore_errors=True) - - # Ensure server versions exist - for ver in plan["create_versions"]: - vdir = os.path.join(local_model_dir, ver) - os.makedirs(vdir, exist_ok=True) - log.info(f"Created version dir: {vdir}") - - # For shared versions, remove extra files + download missing - for ver, vplan in plan["per_version"].items(): - vdir = os.path.join(local_model_dir, ver) - - # Remove extra files - for fname in vplan["remove_files"]: - fpath = os.path.join(vdir, fname) + server_file_map = {} + for file_info in file_infos: + orig = file_info.get("file_name") + if not orig: + continue + clean_name = _clean_server_filename(orig) + server_file_map[clean_name] = file_info + + server_file_names = set(server_file_map.keys()) + + local_file_names = { + f for f in os.listdir(local_version_dir) + if os.path.isfile(os.path.join(local_version_dir, f)) + } + + # Remove extra local files + for fname in (local_file_names - server_file_names): + fpath = os.path.join(local_version_dir, fname) try: os.remove(fpath) log.info(f"Removed extra file: {fpath}") @@ -292,60 +182,40 @@ def plan_changes_for_model(model: str): except Exception as e: log.warning(f"Failed to remove {fpath}: {e}") - # Download missing files - for file_info in vplan["server_files_info"]: - orig = file_info.get("file_name") - if not orig or "Zone.Identifier" in orig: - continue - fname = orig.replace("@SynoEAStream", "") - if fname not in vplan["download_files"]: - continue + # Download missing files only + for fname in (server_file_names - local_file_names): + file_info = server_file_map[fname] furl = file_info.get("url", "") + if not furl: + log.warning(f"Missing URL for {model}/{version}/{fname}") + continue + if not furl.startswith("http"): furl = BASE_FILE_URL + furl + furl = requote_uri(furl) - fpath = os.path.join(vdir, fname) + fpath = os.path.join(local_version_dir, fname) + try: - log.info(f"Downloading {fname} -> {fpath}") + log.info(f"Downloading {model}/{version}/{fname}") robust_download_file(furl, fpath) except Exception as e: - log.error(f"Failed to download {fname}: {e}") + log.error(f"Failed to download {model}/{version}/{fname}: {e}") - # Ensure root config.pbtxt present - cfg_path = os.path.join(local_model_dir, "config.pbtxt") - if not os.path.exists(cfg_path): - cfg_url = requote_uri(f"{BASE_FILE_URL}/models/{model}/config.pbtxt") - try: - log.info(f"Downloading config.pbtxt -> {cfg_path}") - robust_download_file(cfg_url, cfg_path) - except Exception as e: - log.error(f"Failed to download config.pbtxt for {model}: {e}") - - # Reload if it was loaded before changes, or simply ensure it's loaded - # (explicit mode requires an explicit load call) - if will_change or model not in currently_loaded: - triton_load_model(model) - - # Optionally unload models no longer on the server list - if UNLOAD_REMOVED: - still_loaded = loaded_model_names() - to_unload = still_loaded - server_models - for model in to_unload: - log.info(f"Unloading model no longer listed by server: {model}") - triton_unload_model(model) - -# Main Loop -def main_loop(): - # Wait for Triton at startup - if not triton_ready(): - log.error("Triton did not become ready in time.") - else: - log.info("Triton is ready.") + # Optionally remove local models not listed by server + if REMOVE_MISSING_MODELS: + local_models = { + d for d in os.listdir(LOCAL_MODELS_DIR) + if os.path.isdir(os.path.join(LOCAL_MODELS_DIR, d)) + } + + for model in (local_models - server_models): + model_path = os.path.join(LOCAL_MODELS_DIR, model) + log.info(f"Removing local model not listed by server: {model_path}") + shutil.rmtree(model_path, ignore_errors=True) - # Autoload any on-disk models once (explicit mode) - if STARTUP_AUTOLOAD: - autoload_existing_models() +def main_loop(): while True: log.info("Checking server for model updates...") data = get_model_update() @@ -356,8 +226,10 @@ def main_loop(): log.error(f"Error during sync: {e}", exc_info=True) else: log.info("Server unreachable or empty response.") - log.info("Sleeping 60s...") - time.sleep(60) + + log.info(f"Sleeping {SYNC_INTERVAL}s...") + time.sleep(SYNC_INTERVAL) + if __name__ == "__main__": main_loop() diff --git a/sparrow/requirements.txt b/sparrow/requirements.txt index f7ba973..1a38286 100644 --- a/sparrow/requirements.txt +++ b/sparrow/requirements.txt @@ -1,7 +1,7 @@ aiosmtpd==1.4.6 -requests==2.32.4 -urllib3==2.6.3 -idna==3.7 +requests==2.33.0 +urllib3==2.7.0 +idna==3.15 charset-normalizer==3.3.2 certifi==2024.7.4 schedule==1.2.2 @@ -10,19 +10,19 @@ filelock==3.20.3 smbus2==0.4.1 pyserial==3.5 tqdm==4.66.5 -Pillow==12.1.1 +Pillow==12.2.0 numpy==1.26.4 -pandas==1.4.4 -torch==2.8.0 -torchvision==0.23.0 -torchaudio==2.8.0 -tritonclient[http]==2.26.0 +pandas==2.2.3 +torch==2.10.0 +torchvision==0.25.0 +torchaudio==2.10.0 protobuf==5.29.6 soundfile==0.12.1 audioread==3.0.1 pooch==1.8.2 librosa==0.10.2.post1 -numba==0.59.1 -llvmlite==0.42.0 +numba==0.60.0 +llvmlite==0.43.0 pyftpdlib==2.1.0 +onnxruntime==1.20.1 piexif==1.1.3 \ No newline at end of file diff --git a/sparrow/rest_client.py b/sparrow/rest_client.py index 9ac83f9..2afcb5e 100644 --- a/sparrow/rest_client.py +++ b/sparrow/rest_client.py @@ -44,9 +44,9 @@ csv_file = "/app/static/data/detections.csv" logs_dir = "/app/logs/restclient_logs.log" -SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrow-earth.com").rstrip("/") +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrowstudio.azure.com/v1").rstrip("/") -image_server_url = f"{SERVER_BASE_URL}/uploads" +image_server_url = f"{SERVER_BASE_URL}/uploads/image" audio_server_url = f"{SERVER_BASE_URL}/audio_uploads" system_metrics_url = f"{SERVER_BASE_URL}/system_metrics" @@ -70,12 +70,18 @@ logger.critical("Cannot proceed without a valid unique_id.") exit(1) -# VE.Direct (Solar) +# VE.Direct (Solar). Use a Victron-specific by-id symlink so this never grabs +# the XBee FTDI adapter. When the VE.Direct cable isn't plugged in, the symlink +# wont exist and serial.Serial raises FileNotFoundError, falling into ved=None. +VE_DIRECT_PORT = os.environ.get( + "VE_DIRECT_PORT", + "/dev/serial/by-id/usb-VictronEnergy_VE.Direct_cable-if00-port0", +) try: - ved = serial.Serial("/dev/ttyUSB0", 19200, timeout=1) - logger.info("Opened VE.Direct on /dev/ttyUSB0") + ved = serial.Serial(VE_DIRECT_PORT, 19200, timeout=1) + logger.info(f"Opened VE.Direct on {VE_DIRECT_PORT}") except Exception as e: - logger.error(f"Could not open VE.Direct port: {e}") + logger.error(f"Could not open VE.Direct port {VE_DIRECT_PORT}: {e}") ved = None # Helper Functions @@ -248,40 +254,74 @@ def send_backlog_metrics(): bus = None # Upload Functions +def _load_sidecar_or_fallback(image_path, csv_rows): + """Return server-ready metadata dict. Prefer the sidecar JSON written by inference.py; + fall back to a minimal payload built from CSV rows when no sidecar exists (covers + JPEGs left over from before this change).""" + image_name = os.path.basename(image_path) + side_path = os.path.splitext(image_path)[0] + ".json" + if os.path.isfile(side_path): + try: + with open(side_path, "r") as f: + return json.load(f) + except Exception as e: + logger.warning(f"Failed to read sidecar {side_path}: {e}; falling back to CSV-only metadata.") + + captured_at = csv_rows[0].get("Date", "") if csv_rows else "" + return { + "image_name": image_name, + "captured_at": captured_at, + "detections": [], + } + + def upload_image_and_data(image_path, detection_data_list): - """Upload one image with all matching detection rows; delete CSV rows on success elsewhere.""" + """Upload one image plus its sidecar metadata in a single POST to /uploads/image.""" image_name = os.path.basename(image_path) if not is_server_online(image_server_url): logger.warning(f"Image server appears offline, skipping upload for {image_name}.") return False - success = True - for detection_data in detection_data_list: - try: - with open(image_path, "rb") as image_file: - files = {"file": (image_name, image_file, "image/jpeg")} - data = { - "auth_key": auth_key, - "unique_id": unique_id, - "image_name": detection_data["Image Name"], - "detection": detection_data["Detection"], - "confidence": float(detection_data["Confidence Score"]), - "date": detection_data["Date"], - } - logger.info("Sending image data to server: %s", - {k: v for k, v in data.items() if k != "auth_key"}) - response = requests.post(image_server_url, files=files, data=data, timeout=30) - response.raise_for_status() - except requests.exceptions.RequestException as e: - logger.error(f"Failed to upload {image_name} with detection {detection_data['Detection']}: {e}") - success = False - else: - logger.info(f"Successfully uploaded {image_name} with detection {detection_data['Detection']}") - return success + meta = _load_sidecar_or_fallback(image_path, detection_data_list) + detections = meta.get("detections", []) + + try: + with open(image_path, "rb") as image_file: + files = {"file": (image_name, image_file, "image/jpeg")} + data = { + "auth_key": auth_key, + "unique_id": unique_id, + "metadata": json.dumps(meta), + } + logger.info( + "Sending image to server: image=%s detections=%d captured_at=%s", + image_name, len(detections), meta.get("captured_at", "") + ) + response = requests.post(image_server_url, files=files, data=data, timeout=30) + response.raise_for_status() + except requests.exceptions.RequestException as e: + logger.error(f"Failed to upload {image_name}: {e}") + return False + + logger.info(f"Successfully uploaded {image_name} ({len(detections)} detections)") + return True + +_AUDIO_MIME_BY_EXT = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", +} + def upload_audio_file(audio_path): - """Upload a single .wav file; return True on success.""" + """Upload a single audio file (.wav or .mp3); return True on success. + + MP3s come from robin's on-device bird detector (already filtered with + keep_blanks=false and encoded by lameenc); WAVs come from the master's + own audio.py pipeline. MIME is picked from the extension. + """ audio_name = os.path.basename(audio_path) + ext = os.path.splitext(audio_name)[1].lower() + mime = _AUDIO_MIME_BY_EXT.get(ext, "application/octet-stream") if not is_server_online(audio_server_url): logger.warning(f"Server appears offline, skipping upload for {audio_name}.") @@ -289,7 +329,7 @@ def upload_audio_file(audio_path): try: with open(audio_path, "rb") as audio_file: - files = {"file": (audio_name, audio_file, "audio/wav")} + files = {"file": (audio_name, audio_file, mime)} data = {"auth_key": auth_key, "unique_id": unique_id} response = requests.post(audio_server_url, files=files, data=data, timeout=30) response.raise_for_status() @@ -297,7 +337,7 @@ def upload_audio_file(audio_path): logger.error(f"Failed to upload audio file {audio_name}: {e}") return False else: - logger.info(f"Successfully uploaded audio file: {audio_name}") + logger.info(f"Successfully uploaded audio file: {audio_name} ({mime})") return True # Processing Functions @@ -348,21 +388,37 @@ def process_and_upload_images(): return for image_name in os.listdir(image_output_dir): + if not image_name.lower().endswith((".jpg", ".jpeg", ".png")): + continue image_path = os.path.join(image_output_dir, image_name) - if os.path.isfile(image_path): - logger.info(f"Processing image {image_name}.") - detection_data_list = detection_records.get(image_name, []) - if detection_data_list: - success = upload_image_and_data(image_path, detection_data_list) - if success: - remove_records_from_csv(image_name) - try: - os.remove(image_path) - logger.info(f"Deleted local image file: {image_path}") - except Exception as e: - logger.error(f"Failed to delete image file {image_path}: {e}") - else: - logger.warning(f"No corresponding detection records found for {image_name}.") + if not os.path.isfile(image_path): + continue + + logger.info(f"Processing image {image_name}.") + detection_data_list = detection_records.get(image_name, []) + if not detection_data_list: + logger.warning(f"No corresponding detection records found for {image_name}.") + continue + + success = upload_image_and_data(image_path, detection_data_list) + if not success: + continue + + remove_records_from_csv(image_name) + try: + os.remove(image_path) + logger.info(f"Deleted local image file: {image_path}") + except Exception as e: + logger.error(f"Failed to delete image file {image_path}: {e}") + + side_path = os.path.splitext(image_path)[0] + ".json" + try: + os.remove(side_path) + logger.info(f"Deleted sidecar: {side_path}") + except FileNotFoundError: + pass + except Exception as e: + logger.error(f"Failed to delete sidecar {side_path}: {e}") def process_and_upload_audio(): """ @@ -376,7 +432,8 @@ def process_and_upload_audio(): logger.warning(f"Audio output directory {audio_output_dir} not found.") return - audio_files = [a for a in os.listdir(audio_output_dir) if a.lower().endswith(".wav")] + audio_files = [a for a in os.listdir(audio_output_dir) + if a.lower().endswith((".wav", ".mp3"))] total_files = len(audio_files) if total_files == 0: logger.info("No audio files found for processing.") diff --git a/sparrow/static/data/detections.csv b/sparrow/static/data/detections.csv new file mode 100644 index 0000000..07f4bf6 --- /dev/null +++ b/sparrow/static/data/detections.csv @@ -0,0 +1 @@ +Image Name,Detection,Confidence Score,Date diff --git a/sparrow/static/data/metrics_backlog.jsonl b/sparrow/static/data/metrics_backlog.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/sparrow/supervisord.conf b/sparrow/supervisord.conf index 6371ef2..6d60665 100644 --- a/sparrow/supervisord.conf +++ b/sparrow/supervisord.conf @@ -1,6 +1,15 @@ [supervisord] nodaemon=true +[program:xbee_master] +command=python /app/xbee_master_collect.py --port /dev/xbee_serial --baud 115200 --out /app/images --audio-out /app/recordings --log /app/logs/xbee_master.log --log-bytes 5000000 --log-backups 10 --log-level INFO +directory=/app +autostart=true +autorestart=true +priority=15 +stdout_logfile=/app/logs/xbee_master_supervisor.out.log +stderr_logfile=/app/logs/xbee_master_supervisor.err.log + [program:ftp_server] command=python /app/ftp_server.py autostart=true @@ -9,16 +18,6 @@ priority=20 stdout_logfile=/dev/null stderr_logfile=/dev/null -[program:rtc_sync_once] -directory=/app/tools -command=python /app/tools/rtc_sync.py -autostart=true -autorestart=false -startretries=0 -priority=5 -stdout_logfile=/dev/null -stderr_logfile=/dev/null - [program:controller_settings] command=python /app/controller_settings.py autostart=true diff --git a/sparrow/tools/rtc_sync.py b/sparrow/tools/rtc_sync.py deleted file mode 100644 index f8a28c0..0000000 --- a/sparrow/tools/rtc_sync.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -""" -One-shot DS3231 -> system clock sync. -Runs once on container start, logs the RTC time and result, then exits. -""" - -import os -import logging -import smbus2 -import subprocess -from datetime import datetime - -# Configuration -LOG_DIR = "/app/logs" -LOG_FILE = os.path.join(LOG_DIR, "rtc_sync.log") - -I2C_BUS = 7 # I2C BUS -DS3231_ADDR = 0x68 # DS3231 I2C address - -# Logger Setup -os.makedirs(LOG_DIR, exist_ok=True) -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[logging.FileHandler(LOG_FILE)], -) -logger = logging.getLogger(__name__) - -def _bcd_to_int(b: int) -> int: - """Convert a BCD-encoded byte to int.""" - return ((b >> 4) * 10) + (b & 0x0F) - -def _read_rtc_datetime(bus) -> datetime: - """ - Read date/time from DS3231 and return a timezone-agnostic UTC datetime. - Registers 0x00..0x06: sec, min, hour, wday, day, month, year(00..99). - """ - data = bus.read_i2c_block_data(DS3231_ADDR, 0x00, 7) - sec = _bcd_to_int(data[0] & 0x7F) - minute = _bcd_to_int(data[1] & 0x7F) - hour = _bcd_to_int(data[2] & 0x3F) # 24h - day = _bcd_to_int(data[4] & 0x3F) - month = _bcd_to_int(data[5] & 0x1F) - year = 2000 + _bcd_to_int(data[6]) - return datetime(year, month, day, hour, minute, sec) - -def _set_system_time_utc(dt: datetime) -> None: - """ - Set the container's system clock (UTC) from a datetime. - Uses `date -u -s` so no timezone offset is applied here. - """ - ts = dt.strftime("%Y-%m-%d %H:%M:%S") - subprocess.run(["date", "-u", "-s", ts], check=True) - -# Main -def main() -> int: - # Open I2C bus - try: - bus = smbus2.SMBus(I2C_BUS) - except Exception as e: - logger.error(f"I2C bus {I2C_BUS} not available; skipping RTC sync: {e}") - return 0 - - try: - rtc_dt = _read_rtc_datetime(bus) - logger.info(f"RTC time (DS3231 @0x{DS3231_ADDR:02X}): {rtc_dt.isoformat()}Z") - _set_system_time_utc(rtc_dt) - logger.info("System clock updated from RTC (UTC).") - except Exception as e: - logger.error(f"RTC sync failed: {e}") - finally: - try: - bus.close() - except Exception: - pass - - return 0 - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/sparrow/utils/sensors.py b/sparrow/utils/sensors.py index 1640623..0a75c70 100644 --- a/sparrow/utils/sensors.py +++ b/sparrow/utils/sensors.py @@ -10,17 +10,38 @@ import time import logging import threading -from smbus2 import i2c_msg +from smbus2 import SMBus, i2c_msg from typing import Dict, Any, Optional, Callable # Logger logger = logging.getLogger(__name__) # Sensor Configuration -I2C_BUS = 7 +# Prefer Jetson I2C bus 7, fall back to Raspberry Pi bus 1 +I2C_BUS = 1 +PREFERRED_I2C_BUSES = (7, I2C_BUS) + SHTC3_ADDRESS = 0x70 # SHTC3 BME688_DEFAULT = 0x77 # default (0x76 alternate) +def open_i2c_bus(preferred_buses=PREFERRED_I2C_BUSES): + """ + Try opening I2C buses in order. + Returns an SMBus instance or None. + """ + last_err = None + for bus_id in preferred_buses: + try: + bus = SMBus(bus_id) + bus._bus_id = bus_id + logger.info(f"Opened I2C bus {bus_id}") + return bus + except Exception as e: + last_err = e + logger.debug(f"Failed to open I2C bus {bus_id}: {e}") + logger.warning(f"Could not open any I2C bus {preferred_buses}: {last_err}") + return None + # Registry & Types SensorDetect = Callable[[Any], Optional[Dict[str, Any]]] SensorRead = Callable[[Any, Dict[str, Any]], Dict[str, Any]] diff --git a/sparrow/xbee_configure.py b/sparrow/xbee_configure.py new file mode 100644 index 0000000..a6411e3 --- /dev/null +++ b/sparrow/xbee_configure.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +import argparse +import time +import sys +import serial + +DEFAULT_PROBE_BAUDS = [115200, 9600, 57600, 19200, 38400, 230400] + +def read_until(ser: serial.Serial, timeout_s: float = 1.5) -> str: + """Read whatever arrives until timeout.""" + end = time.time() + timeout_s + buf = bytearray() + while time.time() < end: + chunk = ser.read(ser.in_waiting or 1) + if chunk: + buf.extend(chunk) + # small wait to allow response completion + time.sleep(0.05) + else: + time.sleep(0.02) + try: + return buf.decode(errors="replace") + except Exception: + return repr(buf) + +def write_line(ser: serial.Serial, s: str): + ser.write((s + "\r").encode("ascii")) + ser.flush() + +def enter_command_mode(ser: serial.Serial) -> bool: + """ + Enter AT command mode: 1s silence, '+++', 1s silence. + Must NOT send Enter after +++. + """ + ser.reset_input_buffer() + ser.reset_output_buffer() + + time.sleep(1.1) + ser.write(b"+++") + ser.flush() + time.sleep(1.1) + + resp = read_until(ser, 1.5) + return "OK" in resp + +def at(ser: serial.Serial, cmd: str, expect_ok: bool = True) -> str: + """ + Send an AT command (without the leading 'AT') and return response text. + Example cmd: 'AP1' or 'BD7' or 'NIROBIN' + """ + ser.reset_input_buffer() + write_line(ser, "AT" + cmd) + resp = read_until(ser, 1.5).strip() + if expect_ok and ("OK" not in resp) and ("ERROR" in resp or resp == ""): + raise RuntimeError(f"AT{cmd} failed, response: {resp!r}") + return resp + +def configure_xbee( + port: str, + probe_bauds, + final_baud: int, + api_mode: int, + rf_rate: int, + router_mode: int, + network_id_hex: str | None, + node_id: str | None, +): + # 1) Probe to find current baud that allows +++ + ser = None + working_baud = None + for b in probe_bauds: + try: + ser = serial.Serial(port, b, timeout=0.1) + if enter_command_mode(ser): + working_baud = b + break + ser.close() + except Exception: + try: + if ser: + ser.close() + except Exception: + pass + ser = None + + if working_baud is None or ser is None: + raise RuntimeError( + f"Could not enter command mode on {port} using bauds {probe_bauds}. " + "Check wiring/power, or confirm the XBee is not already in API-only mode with config pin forcing." + ) + + print(f"[OK] Entered command mode on {port} @ {working_baud} baud") + + # 2) Apply settings (queued + write) + # API mode: AP=1 (no escapes) is typically best unless you need escaped control chars. + print(f"Setting AP={api_mode} (API mode) ...") + at(ser, f"AP{api_mode}") + + # Routing: CE=0 Standard router (routes packets) + print(f"Setting CE={router_mode} (routing/messaging mode) ...") + at(ser, f"CE{router_mode}") + + # RF data rate: BR=1 for 80kbps (all nodes must match) + print(f"Setting BR={rf_rate} (RF data rate) ...") + at(ser, f"BR{rf_rate}") + + # Optional Network ID (hex). Example: 1234 + if network_id_hex: + nid = network_id_hex.lower().replace("0x", "") + if not all(c in "0123456789abcdef" for c in nid) or len(nid) > 8: + raise ValueError("network_id must be hex up to 32-bit, e.g. 0x1234 or 1234") + print(f"Setting ID=0x{nid} (Network ID) ...") + at(ser, f"ID{nid}") + + # Optional node identifier (NI) (ASCII) + if node_id: + # NI cannot start with space per Digi docs; keep it simple. + safe = node_id.strip() + if not safe: + raise ValueError("node_id cannot be empty/whitespace") + print(f"Setting NI={safe} (Node Identifier) ...") + at(ser, f"NI{safe}", expect_ok=True) + + # Set UART baud (BD). Use standard numeric mapping if possible. + # Common: 7=115200, 8=230400 (per Digi SX 868 guide). + bd_map = {1200:0, 2400:1, 4800:2, 9600:3, 19200:4, 38400:5, 57600:6, 115200:7, 230400:8} + if final_baud in bd_map: + bd_val = bd_map[final_baud] + print(f"Setting BD={bd_val} (UART baud {final_baud}) ...") + at(ser, f"BD{bd_val}") + else: + # Non-standard: send as hex of baud * 256? Digi supports β€œnon-standard BD” + # We avoid that here; keep to standard table for reliability. + raise ValueError(f"final_baud must be one of {sorted(bd_map.keys())}") + + # Write settings to flash and apply + print("Writing to flash (WR) ...") + at(ser, "WR") + print("Applying changes (AC) ...") + at(ser, "AC") + + # Exit command mode + print("Exiting command mode (CN) ...") + at(ser, "CN", expect_ok=False) + + ser.close() + + # 3) Re-open at final baud just to confirm we can still talk (enter cmd mode again) + ser2 = serial.Serial(port, final_baud, timeout=0.1) + ok = enter_command_mode(ser2) + if not ok: + print(f"[WARN] Re-opened at {final_baud} but couldn't re-enter command mode. " + "This can happen if timing is off; API mode may still be set correctly.") + else: + # Read back key params + ap = at(ser2, "AP", expect_ok=False) + br = at(ser2, "BR", expect_ok=False) + ce = at(ser2, "CE", expect_ok=False) + bd = at(ser2, "BD", expect_ok=False) + print(f"[VERIFY] AP={ap.strip()} BR={br.strip()} CE={ce.strip()} BD={bd.strip()}") + at(ser2, "CN", expect_ok=False) + ser2.close() + +def main(): + p = argparse.ArgumentParser(description="Configure Digi XBee 868 over USB serial (AT command mode).") + p.add_argument("--port", default="/dev/ttyUSB0", help="Serial port (default: /dev/ttyUSB0)") + p.add_argument("--probe-baud", action="append", type=int, + help="Add a baud to probe list (can repeat). Default probes common bauds.") + p.add_argument("--baud", type=int, default=115200, help="Final UART baud (115200 or 230400 recommended).") + p.add_argument("--api", type=int, default=1, choices=[0,1,2], + help="AP API mode: 0=Transparent, 1=API no-escapes, 2=API escaped") + p.add_argument("--rf", type=int, default=1, choices=[0,1], + help="BR RF rate: 0=10kbps, 1=80kbps (fast)") + p.add_argument("--router", type=int, default=0, choices=[0,1,2,3,4,6], + help="CE routing/messaging mode (0=standard router)") + p.add_argument("--netid", default=None, help="Network ID (hex), e.g. 1234 or 0x1234") + p.add_argument("--node", default=None, help="Node identifier (NI), e.g. SPARROW_GATEWAY") + args = p.parse_args() + + probe = args.probe_baud[:] if args.probe_baud else DEFAULT_PROBE_BAUDS + + configure_xbee( + port=args.port, + probe_bauds=probe, + final_baud=args.baud, + api_mode=args.api, + rf_rate=args.rf, + router_mode=args.router, + network_id_hex=args.netid, + node_id=args.node, + ) + print("[DONE] Configuration complete.") + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"[ERROR] {e}") + sys.exit(2) diff --git a/sparrow/xbee_master_collect.py b/sparrow/xbee_master_collect.py new file mode 100644 index 0000000..41c0831 --- /dev/null +++ b/sparrow/xbee_master_collect.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 +""" +xbee_master_collect.py + +Hardening: +- Serial open retry loop +- Serial read/write recovery loop +- Preserves pending RD queues across reconnects +- Clears active transfer and partial reassembly on reconnect +- Resets serial buffers after open +- Reopens serial if no frames are seen for a while + +Startup logging: +- Reads and logs local XBee NI / SH / SL / 64-bit address at startup +""" + +import os +import time +import struct +import argparse +import binascii +import serial +import logging +from logging.handlers import RotatingFileHandler +from collections import deque + +START_DELIM = 0x7E + + +# -------------------- Logging -------------------- + +def setup_logger(log_path: str, max_bytes: int, backup_count: int, console: bool, level: str) -> logging.Logger: + logger = logging.getLogger("xbee_master") + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + logger.propagate = False + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + + if not logger.handlers: + log_dir = os.path.dirname(log_path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + fh = RotatingFileHandler(log_path, maxBytes=max_bytes, backupCount=backup_count) + fh.setFormatter(fmt) + fh.setLevel(logger.level) + logger.addHandler(fh) + + if console: + ch = logging.StreamHandler() + ch.setFormatter(fmt) + ch.setLevel(logger.level) + logger.addHandler(ch) + + return logger + + +# -------------------- XBee framing -------------------- + +def read_exact(ser: serial.Serial, n: int) -> bytes | None: + buf = bytearray() + while len(buf) < n: + chunk = ser.read(n - len(buf)) + if not chunk: + return None + buf.extend(chunk) + return bytes(buf) + + +def read_api_frame(ser: serial.Serial) -> bytes | None: + """Read one full valid XBee API frame and return its frame_data.""" + while True: + b = ser.read(1) + if not b: + return None + if b[0] != START_DELIM: + continue + + ln = read_exact(ser, 2) + if not ln: + continue + + length = struct.unpack(">H", ln)[0] + data = read_exact(ser, length) + if not data: + continue + + cks = read_exact(ser, 1) + if not cks: + continue + + if ((sum(data) + cks[0]) & 0xFF) != 0xFF: + continue + + return data + + +def xbee_checksum(frame_data: bytes) -> int: + return (0xFF - (sum(frame_data) & 0xFF)) & 0xFF + + +def build_api_frame(frame_data: bytes) -> bytes: + length = len(frame_data) + return bytes([START_DELIM]) + struct.pack(">H", length) + frame_data + bytes([xbee_checksum(frame_data)]) + + +def build_tx_request_0x10(frame_id: int, dest64_int: int, rf_data: bytes) -> bytes: + frame_type = 0x10 + dest16 = 0xFFFE + broadcast_radius = 0x00 + options = 0x00 + frame = struct.pack(">BBQHBB", frame_type, frame_id, dest64_int, dest16, broadcast_radius, options) + rf_data + return build_api_frame(frame) + + +def build_at_command_0x08(frame_id: int, at_cmd: str, parameter: bytes = b"") -> bytes: + frame_type = 0x08 + frame = struct.pack(">BB2s", frame_type, frame_id, at_cmd.encode("ascii")) + parameter + return build_api_frame(frame) + + +def src64_bytes_to_int(src64_b: bytes) -> int: + return int.from_bytes(src64_b, "big", signed=False) + + +def fmt64(x: int) -> str: + return f"{x:016X}" + + +def open_serial_forever(port: str, baud: int, logger: logging.Logger, timeout: float = 0.2) -> serial.Serial: + while True: + try: + ser = serial.Serial(port, baud, timeout=timeout) + + try: + ser.reset_input_buffer() + except Exception: + pass + + try: + ser.reset_output_buffer() + except Exception: + pass + + time.sleep(0.5) + + logger.info("SERIAL open ok port=%s baud=%d timeout=%.1f", port, baud, timeout) + return ser + except (serial.SerialException, OSError) as e: + logger.error("SERIAL open failed port=%s err=%s; retrying in 2s", port, e) + time.sleep(2) + + +def xbee_local_at(ser: serial.Serial, at_cmd: str, logger: logging.Logger, timeout_s: float = 2.0) -> bytes | None: + """ + Send a local AT command (0x08) and wait for AT Command Response (0x88). + Returns parameter bytes on success, or None. + """ + frame_id = 0x52 + ser.write(build_at_command_0x08(frame_id, at_cmd)) + ser.flush() + + deadline = time.time() + timeout_s + while time.time() < deadline: + frame = read_api_frame(ser) + if not frame: + continue + if frame[0] != 0x88 or len(frame) < 5: + continue + if frame[1] != frame_id: + continue + if frame[2:4] != at_cmd.encode("ascii"): + continue + + status = frame[4] + if status != 0x00: + logger.warning("AT %s failed status=0x%02X", at_cmd, status) + return None + return frame[5:] + + logger.warning("AT %s timed out", at_cmd) + return None + + +# -------------------- Main -------------------- + +def main(): + ap = argparse.ArgumentParser(description="DigiMesh master with reconnecting serial.") + ap.add_argument("--port", required=True, help="Serial port") + ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("--out", default="received_images", help="Output directory for images (.jpg/.jpeg/.png)") + ap.add_argument("--audio-out", default=None, + help="Output directory for received audio (.mp3/.wav). " + "Defaults to /audio if not set. In production this is /app/recordings.") + ap.add_argument("--session-timeout", type=float, default=30.0, help="Seconds before abandoning a stalled session") + ap.add_argument("--ready-ttl", type=float, default=300.0, help="Seconds before dropping a stale RD/session") + ap.add_argument("--max-devices", type=int, default=500, help="Safety cap for number of tracked devices") + ap.add_argument("--per-device-cap", type=int, default=2, help="Max pending sessions kept per device") + ap.add_argument("--coalesce-latest", action="store_true", + help="If set, keep ONLY the latest RD per device (drops older pending for that device)") + ap.add_argument("--serial-idle-reopen", type=float, default=90.0, + help="Reopen serial if no valid frames are seen for this many seconds") + + ap.add_argument("--log", default="logs/xbee_master.log", help="Log file path (rotating)") + ap.add_argument("--log-bytes", type=int, default=5_000_000, help="Rotate after N bytes") + ap.add_argument("--log-backups", type=int, default=5, help="How many rotated logs to keep") + ap.add_argument("--no-console", action="store_true", help="Disable console logging") + ap.add_argument("--log-level", default="INFO", help="DEBUG, INFO, WARNING, ERROR") + + args = ap.parse_args() + + logger = setup_logger( + log_path=args.log, + max_bytes=args.log_bytes, + backup_count=args.log_backups, + console=(not args.no_console), + level=args.log_level, + ) + + os.makedirs(args.out, exist_ok=True) + audio_out = args.audio_out or os.path.join(args.out, "audio") + os.makedirs(audio_out, exist_ok=True) + + # File-type routing. Robin sends `.jpg` images alongside `.mp3` audio over the + # same XBee link; we split them at save time so each downstream pipeline + # (inference.py for images, rest_client for audio) finds only what it expects. + IMAGE_EXTS = (".jpg", ".jpeg", ".png") + AUDIO_EXTS = (".mp3", ".wav") + + def out_dir_for(name: str) -> str: + ext = os.path.splitext(name)[1].lower() + if ext in AUDIO_EXTS: + return audio_out + # Unknown extensions land with images so they're visible and not silently dropped. + return args.out + + dev_queues: dict[int, deque] = {} + rr = deque() + rr_set = set() + + active = None + sessions: dict[tuple[int, int], dict] = {} + + frame_id = 1 + ser = None + master_id_logged = False + last_frame_time = time.time() + + def next_frame_id(cur: int) -> int: + return (cur % 255) + 1 + + def send_to(src64_int: int, rf: bytes): + nonlocal frame_id, ser + tx = build_tx_request_0x10(frame_id, src64_int, rf) + ser.write(tx) + ser.flush() + frame_id = next_frame_id(frame_id) + + def reset_link_state(reason: str): + nonlocal active, sessions, master_id_logged + if active is not None or sessions: + logger.warning( + "LINK reset reason=%s clearing active=%s partial_sessions=%d", + reason, + "none" if active is None else f"{fmt64(active['src64'])}/{active['sid']:08x}", + len(sessions), + ) + active = None + sessions.clear() + master_id_logged = False + + def log_local_xbee_identity(): + nonlocal master_id_logged, ser + if master_id_logged or ser is None: + return + + try: + ni_b = xbee_local_at(ser, "NI", logger) + sh_b = xbee_local_at(ser, "SH", logger) + sl_b = xbee_local_at(ser, "SL", logger) + + ni = ni_b.decode("utf-8", errors="replace") if ni_b else "" + sh = int.from_bytes(sh_b, "big") if sh_b else None + sl = int.from_bytes(sl_b, "big") if sl_b else None + + if sh is not None and sl is not None: + addr64 = (sh << 32) | sl + logger.info( + "MASTER identity NI='%s' addr64=%s SH=%08X SL=%08X", + ni, fmt64(addr64), sh, sl + ) + else: + logger.info("MASTER identity NI='%s' addr64=unknown", ni) + + master_id_logged = True + except Exception: + logger.exception("Failed to read local XBee identity") + + def _ensure_device(src64_int: int): + if src64_int not in dev_queues: + if len(dev_queues) >= args.max_devices: + return False + dev_queues[src64_int] = deque() + return True + + def enqueue_ready(src64_int: int, sid: int, meta: dict): + if not _ensure_device(src64_int): + logger.warning("RD drop: max-devices reached; dev=%s sid=%08x", fmt64(src64_int), sid) + return + + q = dev_queues[src64_int] + t_now = time.time() + + for it in q: + if it["sid"] == sid: + it["t_ready"] = t_now + it["meta"] = meta + break + else: + if args.coalesce_latest: + q.clear() + + if args.per_device_cap > 0: + while len(q) >= args.per_device_cap: + dropped = q.popleft() + logger.info("RD cap-drop: dev=%s drop sid=%08x (cap=%d)", fmt64(src64_int), dropped["sid"], args.per_device_cap) + + q.append({"sid": sid, "t_ready": t_now, "meta": meta}) + + if q and (src64_int not in rr_set): + rr.append(src64_int) + rr_set.add(src64_int) + + logger.info( + "RD <- dev=%s sid=%08x name='%s' size=%s dev_q=%d rr=%d", + fmt64(src64_int), + sid, + meta.get("name", ""), + meta.get("size", None), + len(q), + len(rr), + ) + + def pop_next_rr(): + t_now = time.time() + + while rr: + src64_int = rr.popleft() + rr_set.discard(src64_int) + + q = dev_queues.get(src64_int) + if not q: + continue + + while q and (t_now - q[0]["t_ready"]) > args.ready_ttl: + stale = q.popleft() + logger.info("RD stale-drop: dev=%s sid=%08x age=%.1fs", fmt64(src64_int), stale["sid"], (t_now - stale["t_ready"])) + + if not q: + continue + + item = q.popleft() + sid = item["sid"] + meta = item["meta"] + + if q: + rr.append(src64_int) + rr_set.add(src64_int) + + return src64_int, sid, meta + + return None + + def requeue_active_as_ready(): + nonlocal active + if not active: + return + enqueue_ready(active["src64"], active["sid"], active.get("meta", {})) + active = None + + logger.info("MASTER start port=%s baud=%d out=%s audio_out=%s", + args.port, args.baud, os.path.abspath(args.out), os.path.abspath(audio_out)) + logger.info("Fairness: per-device-cap=%d coalesce-latest=%s ready-ttl=%.1fs", + args.per_device_cap, args.coalesce_latest, args.ready_ttl) + + try: + while True: + try: + if ser is None or not ser.is_open: + ser = open_serial_forever(args.port, args.baud, logger, timeout=0.2) + reset_link_state("serial-open") + log_local_xbee_identity() + last_frame_time = time.time() + + if active is None: + nxt = pop_next_rr() + if nxt: + src64_int, sid, meta = nxt + active = { + "src64": src64_int, + "sid": sid, + "t_go": time.time(), + "t_last": time.time(), + "meta": meta, + } + send_to(src64_int, b"GO" + struct.pack(">I", sid)) + logger.info( + "GO -> dev=%s sid=%08x name='%s' size=%s rr=%d dev_q=%d", + fmt64(src64_int), sid, meta.get("name", ""), meta.get("size", None), + len(rr), len(dev_queues.get(src64_int, ())) + ) + + t_now = time.time() + for key, st in list(sessions.items()): + if (t_now - st.get("t_last", st["t0"])) > args.session_timeout: + d, sid = key + logger.warning("DROP stalled session dev=%s sid=%08x", fmt64(d), sid) + sessions.pop(key, None) + + if active is not None and (t_now - active["t_last"]) > args.session_timeout: + logger.warning("TIMEOUT active dev=%s sid=%08x (requeue)", fmt64(active["src64"]), active["sid"]) + requeue_active_as_ready() + + frame = read_api_frame(ser) + if not frame: + if (time.time() - last_frame_time) > args.serial_idle_reopen: + logger.warning("No serial frames for %.1fs; reopening port", args.serial_idle_reopen) + try: + ser.close() + except Exception: + pass + ser = None + reset_link_state("serial-idle") + time.sleep(1) + continue + + last_frame_time = time.time() + + logger.debug("RX raw frame type=0x%02X len=%d", frame[0], len(frame)) + + if frame[0] != 0x90 or len(frame) < 12: + logger.debug("RX ignored frame type=0x%02X len=%d", frame[0], len(frame)) + continue + + src64_int = src64_bytes_to_int(frame[1:9]) + rf = frame[12:] + + logger.debug( + "RX 0x90 from=%s rf=%r rf_hex=%s", + fmt64(src64_int), + rf, + rf.hex() + ) + + if len(rf) < 2: + continue + + if rf.startswith(b"SG?"): + logger.info("SG? <- dev=%s", fmt64(src64_int)) + try: + send_to(src64_int, b"SG!") + logger.info("SG! -> dev=%s", fmt64(src64_int)) + except Exception: + logger.exception("SG! send failed dev=%s", fmt64(src64_int)) + continue + + if rf.startswith(b"RD"): + if len(rf) < (2 + 4 + 4 + 1): + continue + sid = struct.unpack(">I", rf[2:6])[0] + size = struct.unpack(">I", rf[6:10])[0] + name_len = rf[10] + if len(rf) < 11 + name_len: + continue + name = rf[11:11 + name_len].decode("utf-8", errors="replace") + meta = {"name": name, "size": size} + enqueue_ready(src64_int, sid, meta) + continue + + if not rf.startswith(b"RB"): + continue + + if active is None: + continue + if src64_int != active["src64"]: + continue + + if len(rf) < (2 + 4 + 2 + 2 + 1): + continue + + sid = struct.unpack(">I", rf[2:6])[0] + if sid != active["sid"]: + continue + + total_chunks = struct.unpack(">H", rf[6:8])[0] + chunk_index = struct.unpack(">H", rf[8:10])[0] + + if total_chunks == 0: + logger.warning("RX invalid total_chunks dev=%s sid=%08x total=0", fmt64(src64_int), sid) + continue + + if chunk_index >= total_chunks: + logger.warning( + "RX invalid chunk index dev=%s sid=%08x idx=%d total=%d", + fmt64(src64_int), sid, chunk_index, total_chunks + ) + continue + + flags = rf[10] + is_first = (flags & 0x01) != 0 + + pos = 11 + key = (src64_int, sid) + + if key not in sessions: + sessions[key] = { + "total": total_chunks, + "chunks": {}, + "name": None, + "size": None, + "t0": time.time(), + "t_last": time.time(), + } + logger.info("RX start dev=%s sid=%08x total_chunks=%d", fmt64(src64_int), sid, total_chunks) + + st = sessions[key] + st["t_last"] = time.time() + active["t_last"] = time.time() + st["total"] = total_chunks + + if is_first: + if len(rf) < pos + 4 + 1: + continue + size = struct.unpack(">I", rf[pos:pos + 4])[0] + pos += 4 + name_len = rf[pos] + pos += 1 + if len(rf) < pos + name_len: + continue + name = rf[pos:pos + name_len].decode("utf-8", errors="replace") + pos += name_len + st["name"] = name + st["size"] = size + logger.info("RX meta dev=%s sid=%08x name='%s' size=%d", fmt64(src64_int), sid, name, size) + + payload = rf[pos:] + st["chunks"][chunk_index] = payload + + got = len(st["chunks"]) + if got % 50 == 0 or got == total_chunks: + logger.info("RX prog dev=%s sid=%08x chunks=%d/%d", fmt64(src64_int), sid, got, total_chunks) + + if got == total_chunks and st["name"] and st["size"] is not None: + data = bytearray() + for i in range(total_chunks): + part = st["chunks"].get(i) + if part is None: + data = None + break + data.extend(part) + + if data is None: + logger.warning("RX reassemble missing chunk dev=%s sid=%08x (unexpected)", fmt64(src64_int), sid) + continue + + data = data[:st["size"]] + crc32 = binascii.crc32(data) & 0xFFFFFFFF + + base_name = os.path.basename(st["name"]) + out_name = f"{fmt64(src64_int)}_{sid:08x}_{base_name}" + out_path = os.path.join(out_dir_for(base_name), out_name) + with open(out_path, "wb") as f: + f.write(data) + + dt = time.time() - st["t0"] + kbps = (len(data) * 8) / (dt * 1000) if dt > 0 else 0.0 + logger.info( + "SAVED path=%s dev=%s sid=%08x bytes=%d time=%.1fs rate=%.1fkbps crc32=%08x", + out_path, fmt64(src64_int), sid, len(data), dt, kbps, crc32 + ) + + send_to(src64_int, b"OK" + struct.pack(">I", sid) + struct.pack(">I", crc32)) + logger.info("OK -> dev=%s sid=%08x crc32=%08x", fmt64(src64_int), sid, crc32) + + sessions.pop(key, None) + active = None + + except (serial.SerialException, OSError) as e: + logger.error("SERIAL fault err=%s; closing and reopening", e) + try: + if ser is not None: + ser.close() + except Exception: + pass + ser = None + reset_link_state("serial-fault") + time.sleep(1) + + except Exception: + logger.exception("Unhandled exception in main loop; resetting link state") + try: + if ser is not None: + ser.close() + except Exception: + pass + ser = None + reset_link_state("unhandled-exception") + time.sleep(1) + + except KeyboardInterrupt: + logger.info("MASTER exiting (KeyboardInterrupt).") + finally: + try: + if ser is not None: + ser.close() + except Exception: + pass + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/starlink.env b/starlink.env index 8e2a707..4d78c1c 100644 --- a/starlink.env +++ b/starlink.env @@ -2,6 +2,5 @@ TZ=Etc/UTC SYS_PATH=/host/sys PROC_PATH=/host/proc UNIQUE_ID_PATH=/host/etc/unique_id -VEDIRECT_PORT=/dev/ttyUSB0 -VEDIRECT_BAUD=19200 -SERVER_BASE_URL=https://server.sparrow-earth.com +VE_DIRECT_PORT=/dev/serial/by-id/usb-VictronEnergy_VE.Direct_cable-if00-port0 +SERVER_BASE_URL=https://server.sparrowstudio.azure.com/v1 diff --git a/starlink/Dockerfile.starlink b/starlink/Dockerfile.starlink index 6267373..857c159 100644 --- a/starlink/Dockerfile.starlink +++ b/starlink/Dockerfile.starlink @@ -1,27 +1,32 @@ -FROM python:3.10-slim +FROM python:3.12-slim WORKDIR /app -# System deps -RUN apt-get update && apt-get install -y supervisor && \ - apt-get clean && rm -rf /var/lib/apt/lists/* +# System deps: +RUN apt-get update && apt-get install -y \ + supervisor \ + libgpiod3 \ + && apt-get clean && rm -rf /var/lib/apt/lists/* -# Copy requirments COPY requirements.txt /tmp/requirements.txt # Handle arch-specific grpc wheels -RUN true && \ +RUN set -eux; \ ARCH="$(uname -m)"; \ if [ "$ARCH" = "armv7l" ]; then \ NOBIN_OPT="--no-binary=grpcio"; \ else \ NOBIN_OPT=""; \ fi; \ - pip install --no-cache-dir $NOBIN_OPT -r /tmp/requirements.txt + pip install --no-cache-dir $NOBIN_OPT -r /tmp/requirements.txt; \ + \ + # Only install Jetson.GPIO when actually on a Jetson rootfs + if [ -e /etc/nv_tegra_release ]; then \ + pip install --no-cache-dir Jetson.GPIO; \ + fi -# Copy app COPY starlink_sleep_schedule.py /app/ COPY starlink_metrics_logger.py /app/ COPY supervisord.conf /etc/supervisor/supervisord.conf -CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] +CMD ["supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/starlink/config/schedule_config.json b/starlink/config/schedule_config.json deleted file mode 100644 index ca707c0..0000000 --- a/starlink/config/schedule_config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "start_time": "13:40", - "end_time": "13:00", - "timezone": "Europe/London" -} \ No newline at end of file diff --git a/starlink/requirements.txt b/starlink/requirements.txt index 9eb7ba6..98a8b07 100644 --- a/starlink/requirements.txt +++ b/starlink/requirements.txt @@ -1,8 +1,8 @@ grpcio==1.62.2 protobuf==5.29.6 -requests==2.32.4 -Jetson.GPIO +requests==2.33.0 pyserial==3.5 filelock==3.20.3 smbus2==0.4.1 -starlink-grpc-core==1.2.3 \ No newline at end of file +starlink-grpc-core==1.2.3 +rpi-lgpio \ No newline at end of file diff --git a/starlink/starlink_metrics_logger.py b/starlink/starlink_metrics_logger.py index 4cacca3..7bd831e 100644 --- a/starlink/starlink_metrics_logger.py +++ b/starlink/starlink_metrics_logger.py @@ -17,7 +17,7 @@ # Configuration CONFIG_PATH = "/app/config/schedule_config.json" LOG_DIR = "/app/logs" -SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrow-earth.com").rstrip("/") +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrowstudio.azure.com/v1").rstrip("/") API_ENDPOINT = f"{SERVER_BASE_URL}/metrics" with open("/app/config/access_key.txt", "r") as f: AUTH_KEY = f.read().strip() diff --git a/starlink/starlink_sleep_schedule.py b/starlink/starlink_sleep_schedule.py index 4c2a9a1..b1928f6 100644 --- a/starlink/starlink_sleep_schedule.py +++ b/starlink/starlink_sleep_schedule.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """ -Manages Starlink power on a Jetson using a GPIO relay: it fetches a sleep/wake -window from the server, reads battery voltage via VE.Direct (/dev/ttyUSB0), -and toggles power accordingly. It avoids cutting power during Starlink updates -(using gRPC status + optional reboot to install). +Manages Starlink power on a Raspberry Pi using a GPIO relay: it fetches a +sleep/wake window from the server, reads battery voltage via VE.Direct, and +toggles power accordingly. It avoids cutting power during Starlink updates +(using gRPC status + optional reboot to install). The VE.Direct port is read +from the VE_DIRECT_PORT env var, defaulting to the Victron-specific by-id +symlink so it can't collide with the XBee FTDI adapter on /dev/ttyUSB0. """ import os @@ -14,14 +16,27 @@ import requests from datetime import datetime, timezone import threading -import Jetson.GPIO as GPIO import serial import sys from filelock import FileLock, Timeout from starlink_grpc import ChannelContext, status_data +# --------------------------------------------------------------------------- +# GPIO BACKEND (RPi.GPIO) +# --------------------------------------------------------------------------- +GPIO = None + +try: + import RPi.GPIO as _GPIO + GPIO = _GPIO +except Exception: + GPIO = None + # VE.Direct / Paths -VE_DIRECT_PORT = "/dev/ttyUSB0" +VE_DIRECT_PORT = os.environ.get( + "VE_DIRECT_PORT", + "/dev/serial/by-id/usb-VictronEnergy_VE.Direct_cable-if00-port0", +) CONFIG_PATH = "/app/config/schedule_config.json" LOG_DIR = "/app/logs" @@ -40,7 +55,7 @@ logger = logging.getLogger(__name__) # REST API Endpoint & Auth -SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrow-earth.com").rstrip("/") +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://server.sparrowstudio.azure.com/v1").rstrip("/") REST_API_URL = f"{SERVER_BASE_URL}/get_schedule" logger.info(f"Schedule endpoint: {REST_API_URL}") @@ -49,14 +64,23 @@ AUTH_KEY = f.read().strip() except Exception as e: logging.error(f"Failed to read access key from /app/config/access_key.txt: {e}") - exit(1) + sys.exit(1) +# --------------------------------------------------------------------------- # GPIO Relay Setup +# --------------------------------------------------------------------------- PIN_RELAY = 8 # BCM 8 > mikroBUS CS > Relay 2 coil & LED (socket 1) -GPIO.setmode(GPIO.BCM) -GPIO.setup(PIN_RELAY, GPIO.OUT, initial=GPIO.LOW) +if GPIO is not None: + GPIO.setmode(GPIO.BCM) + GPIO.setup(PIN_RELAY, GPIO.OUT, initial=GPIO.LOW) + logger.info(f"RPi.GPIO initialized on BCM pin {PIN_RELAY}.") +else: + logger.warning("RPi.GPIO not available. Relay control will be disabled.") + +# --------------------------------------------------------------------------- # Hardware ID Retrieval +# --------------------------------------------------------------------------- def get_hardware_id(): """ Retrieve a unique hardware ID based on a stored UUID. @@ -83,7 +107,9 @@ def get_hardware_id(): logger.error(f"Failed to retrieve hardware ID: {e}") raise +# --------------------------------------------------------------------------- # VE.Direct Helpers +# --------------------------------------------------------------------------- def read_vedirect_battery_voltage(port=VE_DIRECT_PORT): """ Open VE.Direct at 19 200 baud, look for the 'V' (battery voltage in mV) register, @@ -116,7 +142,9 @@ def read_vedirect_battery_voltage(port=VE_DIRECT_PORT): logger.error(f"VE.Direct port open error: {e}") return None +# --------------------------------------------------------------------------- # Schedule Load/Save +# --------------------------------------------------------------------------- def load_local_schedule(): """Load the local sleep schedule from the configuration file.""" try: @@ -155,18 +183,28 @@ def convert_to_utc_minutes(local_time_str): logger.error(f"Invalid time format '{local_time_str}': {e}") return None +# --------------------------------------------------------------------------- # Relay Control (GPIO only) +# --------------------------------------------------------------------------- def turn_on_starlink(): """Power ON Starlink via GPIO relay.""" + if GPIO is None: + logger.warning("Requested Starlink ON, but no GPIO backend available.") + return GPIO.output(PIN_RELAY, GPIO.LOW) logger.info("Starlink turned ON (GPIO)") def turn_off_starlink(): """Power OFF Starlink via GPIO relay.""" + if GPIO is None: + logger.warning("Requested Starlink OFF, but no GPIO backend available.") + return GPIO.output(PIN_RELAY, GPIO.HIGH) logger.info("Starlink turned OFF (GPIO)") +# --------------------------------------------------------------------------- # Update Status / Control +# --------------------------------------------------------------------------- def is_update_in_progress(): """ Check if a Starlink software update is in progress using the starlink_grpc package. @@ -237,7 +275,9 @@ def log_update_status_periodically(poll_interval=300): logger.info(f"(Periodic) Starlink update status: {status}") time.sleep(poll_interval) +# --------------------------------------------------------------------------- # Schedule Application +# --------------------------------------------------------------------------- def apply_schedule(schedule): """ Apply the sleep/wake schedule using GPIO relay control, @@ -287,12 +327,18 @@ def apply_schedule(schedule): except Exception as e: logger.error(f"Error applying schedule: {e}") +# --------------------------------------------------------------------------- # Remote Schedule Fetch +# --------------------------------------------------------------------------- def fetch_remote_schedule(unique_id): """Fetch the schedule from the REST API.""" try: payload = {"unique_id": unique_id, "auth_key": AUTH_KEY} - headers = {"Content-Type": "application/json"} + headers = { + "Content-Type": "application/json", + "X-API-Key": AUTH_KEY, + "X-Unit-ID": unique_id, + } logger.info(f"Fetching remote schedule for {unique_id}") response = requests.post(REST_API_URL, json=payload, headers=headers, timeout=10) if response.status_code == 200: @@ -319,7 +365,9 @@ def schedules_are_different(local_schedule, remote_schedule): return (local_schedule.get("start_time") != remote_schedule.get("start_time") or local_schedule.get("end_time") != remote_schedule.get("end_time")) +# --------------------------------------------------------------------------- # Main +# --------------------------------------------------------------------------- def main(): logger.info("Starting Starlink sleep schedule manager...") unique_id = get_hardware_id() @@ -340,10 +388,15 @@ def main(): logger.warning("No schedule available.") time.sleep(120) finally: - GPIO.cleanup() - logger.info("GPIO cleaned up; exiting.") + if GPIO is not None: + GPIO.cleanup() + logger.info("GPIO cleaned up; exiting.") + else: + logger.info("No GPIO backend to clean up; exiting.") +# --------------------------------------------------------------------------- # Entry Point (Single-Instance) +# --------------------------------------------------------------------------- if __name__ == "__main__": LOCK_PATH = "/tmp/starlink_schedule.lock" try: diff --git a/updater/README.md b/updater/README.md new file mode 100644 index 0000000..d77ce50 --- /dev/null +++ b/updater/README.md @@ -0,0 +1,133 @@ +# SPARROW auto-updater + +A small bash-based update daemon for deployed SPARROW Pis. It polls +`microsoft/SPARROW` every 15 minutes, deploys the newest **tagged release** +matching `v..
`, rebuilds and recreates the docker-compose +stack, and **auto-rolls-back** if the new build fails to come up healthy. + +Pi-local config (env files, access keys, runtime data, ONNX models, `.git/`) +is never touched. + +## Files + +| Path | Role | +|---|---| +| `updater/sparrow-update.sh` | The script. Runs as root via systemd. | +| `updater/install.sh` | One-time installer; copies the script to `/usr/local/sbin/`, writes the systemd unit + timer + logrotate config. Idempotent. | +| `/etc/sparrow-update.conf` | Optional overrides (`REPO_OWNER`, `TAG_PATTERN`, `KEEP_BACKUPS`, …). Sourced by the script if present. | +| `/var/lib/sparrow-update/` | Runtime state: `current_tag`, `current_sha`, `staging/`, `backup/`, `log/`, `lock`, and the `STUCK` flag. | + +## Bootstrap + +From a fresh field-deployed Pi (or any Pi where the repo is already at +`/home/sparrow/Desktop/system/`): + +```bash +cd /home/sparrow/Desktop/system +sudo bash updater/install.sh +``` + +That's it β€” no prompts, no config. The installer works against the public +`microsoft/SPARROW` repo out of the box. + +### Optional: private fork / auth + +If you're running your own private fork instead of the upstream repo, pass a +GitHub fine-grained PAT (scoped to *Contents: Read-only*) through to the +installer via env var: + +```bash +sudo env UPDATER_GITHUB_TOKEN=github_pat_xxx bash updater/install.sh +``` + +It lands in `/etc/sparrow-update.conf` (mode 0600) and the updater reads it +on every tick. Also override `REPO_OWNER` / `REPO_NAME` in the same file to +point at your fork. + +## Tagging a release + +Auto-deploy fires when a tag matches the date-pattern `vYYYY.MM.DD` +(suffixes like `-fix` or `-hotfix1` are allowed). Examples that **do** trigger +a deploy: `v2026.06.10`, `v2026.06.10-hotfix1`. Examples that **don't**: +`main`, `dev`, `release/2026.06.10`. + +```bash +# from your dev machine, on the commit you want to ship +git tag v2026.06.10 +git push origin v2026.06.10 +``` + +Within 15 minutes (or immediately if you run `sudo systemctl start +sparrow-update.service` on the Pi), the new tag is detected, downloaded, +verified, snapshotted, applied, built, recreated, and health-checked. + +## Operator commands + +```bash +# What's currently deployed? +sudo cat /var/lib/sparrow-update/current_tag +sudo cat /var/lib/sparrow-update/current_sha + +# Force an immediate update tick (still no-op if no new matching tag) +sudo systemctl start sparrow-update.service + +# Tail the activity log +sudo journalctl -u sparrow-update.service -f + +# Long-term log (rotated weekly, 8 kept) +sudo less /var/lib/sparrow-update/log/sparrow-update.log + +# Inspect timer state +systemctl list-timers sparrow-update.timer + +# Pause auto-updates (e.g. during field debugging) +sudo systemctl disable --now sparrow-update.timer +# ... resume +sudo systemctl enable --now sparrow-update.timer +``` + +## The `STUCK` flag + +If both the new deploy AND the auto-rollback fail to pass the health check, +the script creates `/var/lib/sparrow-update/STUCK`. The systemd timer will +keep firing, but the script bails immediately when it sees the flag β€” better +to leave things as-is than to keep churning. + +To clear it after diagnosis: + +```bash +# 1. Fix the underlying issue (e.g. push a corrected tag) +# 2. Optionally restore from a backup manually: +sudo ls /var/lib/sparrow-update/backup/ +# 3. Clear the flag +sudo rm /var/lib/sparrow-update/STUCK +# 4. Trigger a run +sudo systemctl start sparrow-update.service +``` + +## Behaviour guarantees + +- **Single-instance:** `flock` on `/var/lib/sparrow-update/lock` prevents two + updates ever running at once. +- **Atomic-ish swap:** the snapshot is taken before the rsync, so any failure + after rsync but before health-check pass triggers a clean rollback. +- **Pi-local preservation:** every rsync (forward and reverse) carries the + same exclude list, so deployment-specific config and runtime data survive. +- **Bounded history:** only `KEEP_BACKUPS` (default 2) snapshots kept. + Each is ~10 MB (no logs/Models/runtime data inside). +- **Never touches:** `/boot/firmware/config.txt`, the systemd units themselves, + `Models/`, `sparrow/{config,logs,images,recordings,static}/`, + `starlink/{config,logs}/`, `.git/`, or `/usr/local/sbin/sparrow-update.sh` + (the running script never updates itself). + +## Updating the updater + +The script at `/usr/local/sbin/sparrow-update.sh` is intentionally **not** +auto-updated. If `updater/sparrow-update.sh` changes in a release tag and +you want it live, re-run the installer manually after that tag is deployed: + +```bash +sudo bash /home/sparrow/Desktop/system/updater/install.sh +``` + +This is the price of keeping the updater immune to its own bugs. diff --git a/updater/install.sh b/updater/install.sh new file mode 100644 index 0000000..46428b8 --- /dev/null +++ b/updater/install.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +############################################################################### +# install.sh β€” idempotent installer for the SPARROW auto-updater. +# +# Run from anywhere with sudo. Defaults assume this lives at +# /updater/install.sh; the script auto-detects DEPLOY_DIR from its +# own location. +# +# What it does: +# 1. apt-get install -y jq (the only host-side dep not already present) +# 2. Copies sparrow-update.sh -> /usr/local/sbin/ (root-owned, mode 0750) +# 3. Writes /etc/systemd/system/sparrow-update.{service,timer} +# 4. Writes /etc/logrotate.d/sparrow-update +# 5. systemctl daemon-reload && enable --now sparrow-update.timer +# +# Re-running is safe: each write is checked or overwrites with the same content. +############################################################################### +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +SOURCE_SCRIPT="$SCRIPT_DIR/sparrow-update.sh" +TARGET_SCRIPT="/usr/local/sbin/sparrow-update.sh" +SERVICE_FILE="/etc/systemd/system/sparrow-update.service" +TIMER_FILE="/etc/systemd/system/sparrow-update.timer" +LOGROTATE_FILE="/etc/logrotate.d/sparrow-update" +CONFIG_FILE="/etc/sparrow-update.conf" +STATE_DIR="/var/lib/sparrow-update" + +if [[ $EUID -ne 0 ]]; then + echo "ERROR: must run as root (try: sudo $0)" >&2 + exit 1 +fi + +if [[ ! -f "$SOURCE_SCRIPT" ]]; then + echo "ERROR: cannot find sparrow-update.sh at $SOURCE_SCRIPT" >&2 + exit 1 +fi + +# The auto-updater works without any config for public repos. If the repo is +# still private, set UPDATER_GITHUB_TOKEN before running this installer and we +# will persist it to $CONFIG_FILE (mode 0600). An existing $CONFIG_FILE is +# never overwritten β€” operators can edit it directly. +if [[ -n "${UPDATER_GITHUB_TOKEN:-}" && ! -f "$CONFIG_FILE" ]]; then + echo "[install] writing $CONFIG_FILE from UPDATER_GITHUB_TOKEN (mode 0600)" + umask 077 + cat > "$CONFIG_FILE" </dev/null 2>&1; then + apt-get update -y + apt-get install -y jq +fi + +echo "[install] preparing state directory at $STATE_DIR" +install -d -m 0750 "$STATE_DIR" +install -d -m 0750 "$STATE_DIR/staging" "$STATE_DIR/backup" "$STATE_DIR/log" + +echo "[install] copying $SOURCE_SCRIPT -> $TARGET_SCRIPT" +install -m 0750 -o root -g root "$SOURCE_SCRIPT" "$TARGET_SCRIPT" + +echo "[install] writing $SERVICE_FILE" +cat > "$SERVICE_FILE" <<'EOF' +[Unit] +Description=SPARROW automated tag-based self-update +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/sparrow-update.sh +# The script handles its own logging; let stdout/stderr flow into journal +StandardOutput=journal +StandardError=journal +# Generous timeout so a full rebuild + recreate + health-check fits +TimeoutStartSec=30min +EOF + +echo "[install] writing $TIMER_FILE" +cat > "$TIMER_FILE" <<'EOF' +[Unit] +Description=SPARROW auto-update β€” fires every 15 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=15min +RandomizedDelaySec=120 +Persistent=true +Unit=sparrow-update.service + +[Install] +WantedBy=timers.target +EOF + +echo "[install] writing $LOGROTATE_FILE" +cat > "$LOGROTATE_FILE" <<'EOF' +/var/lib/sparrow-update/log/sparrow-update.log { + weekly + rotate 8 + compress + missingok + notifempty + copytruncate +} +EOF + +echo "[install] reloading systemd and enabling timer" +systemctl daemon-reload +systemctl enable --now sparrow-update.timer + +echo +echo "[install] done. Status:" +systemctl --no-pager status sparrow-update.timer || true +echo +echo "Useful commands:" +echo " sudo systemctl start sparrow-update.service # fire one update tick now" +echo " sudo journalctl -u sparrow-update.service -f # tail update activity" +echo " sudo cat /var/lib/sparrow-update/current_tag # what's deployed" +echo " sudo cat /var/lib/sparrow-update/log/sparrow-update.log" diff --git a/updater/sparrow-update.sh b/updater/sparrow-update.sh new file mode 100644 index 0000000..c1057ad --- /dev/null +++ b/updater/sparrow-update.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash +############################################################################### +# sparrow-update.sh β€” automated, safe self-update from GitHub for the SPARROW Pi +# +# Run as root via systemd timer (sparrow-update.timer). Polls GitHub for new +# git tags matching TAG_PATTERN, downloads + validates the tarball, snapshots +# the current deployment, swaps in the new code (preserving Pi-local config), +# rebuilds + recreates the docker-compose stack, runs a health check, and +# auto-rolls-back if anything goes wrong. +# +# See updater/README.md for operator docs. +############################################################################### +set -Eeuo pipefail + +# --------------------------------------------------------------------------- +# Configuration (override in /etc/sparrow-update.conf) +# --------------------------------------------------------------------------- +REPO_OWNER="microsoft" +REPO_NAME="SPARROW" +DEPLOY_DIR="/home/sparrow/Desktop/system" +STATE_DIR="/var/lib/sparrow-update" +LOG_FILE="$STATE_DIR/log/sparrow-update.log" +LOCK_FILE="$STATE_DIR/lock" +STUCK_FILE="$STATE_DIR/STUCK" +# Default to date-style tags like v2026.06.10; override via /etc/sparrow-update.conf +TAG_PATTERN='^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}(-[A-Za-z0-9._-]+)?$' +KEEP_BACKUPS=2 +HEALTH_SLEEP_SECS=60 +HTTP_TIMEOUT=30 +DOWNLOAD_TIMEOUT=180 +USER_AGENT="sparrow-pi-updater" +# GITHUB_TOKEN β€” optional. microsoft/SPARROW is public so no auth is needed; +# left in as a hook if you ever host a private fork. Set in /etc/sparrow-update.conf. +GITHUB_TOKEN="" + +CONFIG_FILE="/etc/sparrow-update.conf" +[[ -r "$CONFIG_FILE" ]] && source "$CONFIG_FILE" + +# Build auth header array used by every curl call. +AUTH_HEADERS=(-H "Accept: application/vnd.github+json" -H "User-Agent: $USER_AGENT") +if [[ -n "$GITHUB_TOKEN" ]]; then + AUTH_HEADERS+=(-H "Authorization: Bearer $GITHUB_TOKEN") +fi + +# Resolved paths after config load +STAGING_DIR="$STATE_DIR/staging" +BACKUP_DIR="$STATE_DIR/backup" +CURRENT_TAG_FILE="$STATE_DIR/current_tag" +CURRENT_SHA_FILE="$STATE_DIR/current_sha" + +# Paths preserved from the running deployment β€” applied as rsync excludes both +# when laying down a new release and when restoring a backup. +PRESERVE_EXCLUDES=( + "--exclude=sparrow.env" + "--exclude=starlink.env" + "--exclude=sparrow/config/" + "--exclude=starlink/config/" + "--exclude=sparrow/logs/" + "--exclude=sparrow/images/" + "--exclude=sparrow/recordings/" + "--exclude=sparrow/static/" + "--exclude=starlink/logs/" + "--exclude=Models/" + "--exclude=.git/" +) + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +mkdir -p "$(dirname "$LOG_FILE")" "$STAGING_DIR" "$BACKUP_DIR" + +log() { + # Writes to stderr (visible to systemd journal) and appends to the log file. + # CRITICAL: never writes to stdout, because callers capture function stdout + # via $() β€” log lines bleeding into that would corrupt results. + local level="$1"; shift + local ts msg + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + msg="$ts $level $*" + printf '%s\n' "$msg" >&2 + printf '%s\n' "$msg" >> "$LOG_FILE" +} + +die() { + log CRITICAL "$*" + exit 1 +} + +on_error() { + local rc=$? + log ERROR "Unexpected exit (rc=$rc) at line ${BASH_LINENO[0]}" + exit "$rc" +} +trap on_error ERR + +# --------------------------------------------------------------------------- +# Single-instance guard via flock +# --------------------------------------------------------------------------- +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log INFO "another update in progress; exiting" + exit 0 +fi + +if [[ -f "$STUCK_FILE" ]]; then + log CRITICAL "STUCK flag present at $STUCK_FILE β€” refusing to run. Operator must clear it after diagnosis." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "required tool missing: $1" +} +require_tool curl +require_tool jq +require_tool tar +require_tool rsync +require_tool sha256sum +require_tool docker-compose +require_tool docker + +read_current_tag() { + [[ -f "$CURRENT_TAG_FILE" ]] && cat "$CURRENT_TAG_FILE" || echo "" +} +read_current_sha() { + [[ -f "$CURRENT_SHA_FILE" ]] && cat "$CURRENT_SHA_FILE" || echo "" +} + +# Clean any leftover staging dirs from prior crashed runs. +clean_stale_staging() { + if [[ -d "$STAGING_DIR" ]]; then + find "$STAGING_DIR" -maxdepth 1 -mindepth 1 \( -type d -o -type f -name '*.tar.gz' \) -print0 \ + | xargs -0 -r rm -rf + fi +} + +# Print "\t" of the newest tag matching TAG_PATTERN, or empty if none. +fetch_latest_matching_tag() { + local api_url="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/tags?per_page=50" + local body + if ! body=$(curl -sf -m "$HTTP_TIMEOUT" "${AUTH_HEADERS[@]}" "$api_url"); then + log WARN "failed to query $api_url (network or auth issue)" + return 1 + fi + # GitHub's tag-order isn't reliable for our purposes (creation time vs + # name vs descending varies). Sort lexicographically ourselves and take + # the highest β€” this gives the natural ordering + # v2026.06.09 < v2026.06.09-hotfix1 < v2026.06.10 + # which is what we want. If no match, jq's `last` yields null and string + # interpolation gives "null\tnull", which the caller treats as "no match". + jq -r --arg pat "$TAG_PATTERN" \ + '[.[] | select(.name | test($pat))] | sort_by(.name) | last | "\(.name)\t\(.commit.sha)"' \ + <<<"$body" +} + +download_tarball() { + local tag="$1" dest="$2" + # Use the API endpoint (not codeload) so an Authorization header works + # against private forks too. The API responds with a 302 redirect to the + # actual tarball for public repos and follows -L below. + local url="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/tarball/refs/tags/${tag}" + log INFO "downloading $url" + curl -sfL -m "$DOWNLOAD_TIMEOUT" "${AUTH_HEADERS[@]}" -o "$dest" "$url" \ + || die "tarball download failed for tag=$tag" +} + +verify_tarball() { + local tarball="$1" extract_dir="$2" + local sha + sha=$(sha256sum "$tarball" | awk '{print $1}') + log INFO "tarball sha256=$sha" + + tar -tzf "$tarball" >/dev/null || die "tarball is not a valid gzip+tar" + + local need=(docker-compose.yml sparrow/Dockerfile sparrow/requirements.txt starlink/Dockerfile.starlink) + local listing + listing=$(tar -tzf "$tarball") + for f in "${need[@]}"; do + # Listing entries look like "microsoft-SPARROW-/path/to/file" + grep -qE "^[^/]+/${f}\$" <<<"$listing" \ + || die "tarball missing required file: $f" + done + + mkdir -p "$extract_dir" + tar -xzf "$tarball" -C "$extract_dir" --strip-components=1 \ + || die "tar extraction failed" +} + +# docker compose syntax check against the extracted compose file. We can't run +# the full validation in the staging dir (paths inside docker-compose.yml are +# relative to the project dir), so we just check YAML + Compose schema by piping. +validate_compose() { + local dir="$1" + ( cd "$dir" && docker-compose -f docker-compose.yml config >/dev/null ) \ + || die "docker-compose config validation failed in $dir" +} + +snapshot_to_backup() { + local tag="$1" + local target="$BACKUP_DIR/$tag" + rm -rf "$target" + mkdir -p "$target" + rsync -a --delete "${PRESERVE_EXCLUDES[@]}" "$DEPLOY_DIR/" "$target/" + log INFO "snapshot saved: $target" +} + +apply_release() { + local src="$1" + rsync -a --delete "${PRESERVE_EXCLUDES[@]}" "$src/" "$DEPLOY_DIR/" +} + +restore_backup() { + local tag="$1" + local src="$BACKUP_DIR/$tag" + [[ -d "$src" ]] || die "no backup to restore at $src" + rsync -a --delete "${PRESERVE_EXCLUDES[@]}" "$src/" "$DEPLOY_DIR/" + log INFO "restored backup: $src" +} + +compose_build() { + ( cd "$DEPLOY_DIR" && docker-compose build 2>&1 | tail -20 | sed 's/^/ | /' ) \ + || return 1 + return 0 +} + +compose_recreate() { + ( cd "$DEPLOY_DIR" && docker-compose up -d --force-recreate 2>&1 | tail -10 | sed 's/^/ | /' ) \ + || return 1 + return 0 +} + +# Health check. Returns 0 on pass, non-zero on fail. Logs each sub-check. +health_check() { + local fail=0 + + # 1. Containers running + for c in sparrow_service starlink_tools_container; do + local state + state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo missing) + if [[ "$state" != "running" ]]; then + log ERROR "HEALTH FAIL: $c state=$state (expected running)" + fail=1 + else + log INFO "HEALTH OK: $c is running" + fi + done + + # 2. No fatal patterns in container logs since the container started + local since + for c in sparrow_service starlink_tools_container; do + since=$(docker inspect -f '{{.State.StartedAt}}' "$c" 2>/dev/null || true) + if [[ -z "$since" ]]; then + continue + fi + local recent + recent=$(docker logs --since "$since" "$c" 2>&1 | tail -500 || true) + local pattern='Traceback \(most recent call last\)|ImportError|ModuleNotFoundError|supervisord: .* exited with non-zero status|CRITICAL.*Cannot proceed' + if grep -qE "$pattern" <<<"$recent"; then + log ERROR "HEALTH FAIL: fatal pattern in $c logs since restart" + fail=1 + else + log INFO "HEALTH OK: no fatal patterns in $c logs" + fi + done + + # 3. Heartbeat logs β€” files that SHOULD see writes inside the polling interval. + # Allow-list approach: only check logs whose owning process polls on a + # known schedule; ignore event-driven logs (ftp/smtp/controller) that + # legitimately stay quiet for hours. + local heartbeats=( + /app/logs/inference.log # model_settings poll ~60s + /app/logs/restclient_logs.log # metrics job every minute + ) + local now + now=$(docker exec sparrow_service date +%s 2>/dev/null || echo 0) + for logf in "${heartbeats[@]}"; do + local age + age=$(docker exec sparrow_service stat -c %Y "$logf" 2>/dev/null || echo 0) + if (( age == 0 )); then + log ERROR "HEALTH FAIL: heartbeat log missing: $logf" + fail=1 + continue + fi + if (( now - age > 120 )); then + log ERROR "HEALTH FAIL: heartbeat log stale: $logf (age $((now-age))s)" + fail=1 + else + log INFO "HEALTH OK: $logf age=$((now-age))s" + fi + done + + return "$fail" +} + +prune_backups() { + local -a backups + mapfile -t backups < <(ls -1t "$BACKUP_DIR" 2>/dev/null || true) + if (( ${#backups[@]} > KEEP_BACKUPS )); then + for old in "${backups[@]:$KEEP_BACKUPS}"; do + rm -rf "${BACKUP_DIR:?}/$old" + log INFO "pruned old backup: $old" + done + fi +} + +# --------------------------------------------------------------------------- +# Rollback +# --------------------------------------------------------------------------- +rollback() { + local previous_tag="$1" reason="$2" + log ERROR "ROLLBACK to $previous_tag (reason: $reason)" + + if [[ -z "$previous_tag" || ! -d "$BACKUP_DIR/$previous_tag" ]]; then + touch "$STUCK_FILE" + die "no backup available to roll back to; STUCK flag set" + fi + + restore_backup "$previous_tag" + + if ! compose_build; then + touch "$STUCK_FILE" + die "rollback rebuild failed; STUCK flag set" + fi + compose_recreate || true + + log INFO "rollback rebuild + recreate complete; waiting ${HEALTH_SLEEP_SECS}s for health check" + sleep "$HEALTH_SLEEP_SECS" + if health_check; then + log INFO "rolled back successfully to $previous_tag" + return 0 + fi + touch "$STUCK_FILE" + die "rollback succeeded structurally but health check still failing; STUCK flag set" +} + +# --------------------------------------------------------------------------- +# Main flow +# --------------------------------------------------------------------------- +log INFO "==== sparrow-update tick: deploy_dir=$DEPLOY_DIR state_dir=$STATE_DIR ====" +clean_stale_staging + +current_tag=$(read_current_tag) +current_sha=$(read_current_sha) +log INFO "current_tag=${current_tag:-} current_sha=${current_sha:-}" + +latest=$(fetch_latest_matching_tag || true) +if [[ -z "$latest" || "$latest" == "null"* ]]; then + log INFO "no tag matching pattern $TAG_PATTERN; nothing to do" + exit 0 +fi + +new_tag=$(cut -f1 <<<"$latest") +new_sha=$(cut -f2 <<<"$latest") +log INFO "latest matching tag: $new_tag ($new_sha)" + +if [[ "$new_tag" == "$current_tag" ]]; then + log INFO "up to date" + exit 0 +fi + +# 5-7. Download + verify + extract +tarball="$STAGING_DIR/${new_tag}.tar.gz" +extract_dir="$STAGING_DIR/$new_tag" +download_tarball "$new_tag" "$tarball" +verify_tarball "$tarball" "$extract_dir" +log INFO "tarball extracted to $extract_dir" + +# 8. Compose-config validation +validate_compose "$extract_dir" + +# 9. Snapshot current deployment for rollback. First-time installs have no +# current_tag, so we snapshot under a synthetic "preinstall" key. +backup_tag="${current_tag:-preinstall-$(date -u +%Y%m%dT%H%M%SZ)}" +snapshot_to_backup "$backup_tag" + +# 10. Apply new release +apply_release "$extract_dir" +log INFO "applied $new_tag to $DEPLOY_DIR" + +# 11. Build +if ! compose_build; then + log ERROR "docker-compose build failed for $new_tag" + rollback "$backup_tag" "build failed" + exit 0 +fi + +# 12. Recreate +compose_recreate || log WARN "compose recreate returned non-zero" + +# 13. Sleep then health check +log INFO "waiting ${HEALTH_SLEEP_SECS}s before health check" +sleep "$HEALTH_SLEEP_SECS" + +# 14. Health check +if health_check; then + echo "$new_tag" > "$CURRENT_TAG_FILE" + echo "$new_sha" > "$CURRENT_SHA_FILE" + log INFO "SUCCESS: deployed $new_tag ($new_sha)" + # Cleanup staging + rm -rf "$extract_dir" "$tarball" + prune_backups + exit 0 +else + rollback "$backup_tag" "health check failed" + # Cleanup staging on failed deploy as well + rm -rf "$extract_dir" "$tarball" + exit 0 +fi