senior-pomidor-plant-v2

Senior Pomidor: Edge Node

Python edge-node software for balcony plant monitoring. The same application runs on Linux and Windows in mock mode; real sensor hardware mode is supported on Linux/Raspberry Pi.

This repository contains only the balcony hardware and telemetry collection layer. The Core AI server, database, and LLM/VLM processing live in a separate repository.

Overview

The Edge Node reads soil, air, light, leaf-temperature, and hardware health sensors, formats the readings into a Senior Pomidor telemetry payload, stores a local copy on the edge node, and publishes the payload to the Core server over MQTT. HTTP is included as an optional fallback transport. The node can also capture local USB camera photos on an independent interval and upload them to the Core server over HTTP multipart.

The application is designed around three constraints:

Platform Modes

Platform Supported mode Notes
Windows Mock sensors Use MOCK_SENSORS=true. Real I2C, SMBus, and 1-Wire sensor access is not supported natively.
Linux desktop/server Mock sensors Useful for development and integration tests.
Raspberry Pi Linux Mock or real sensors Use MOCK_SENSORS=false for real hardware.
Docker on Windows/Linux Mock sensors Use docker-compose.mock.yml; no host hardware passthrough is required.
Docker on Raspberry Pi Linux Real sensors Use docker-compose.yml; it passes through /dev/i2c-1, /dev/video0, and /sys/bus/w1.

If MOCK_SENSORS is omitted, the app defaults to mock mode on non-Linux platforms and real sensor mode on Linux. Setting MOCK_SENSORS=false on Windows is rejected at startup with a configuration error.

Hardware

Sensor Protocol Address / Pin Measurement
ADS1115 I2C 0x48, channels A0, A1 Capacitive soil moisture raw ADC reading, calibrated to percent
BME280 I2C 0x76 Shared air temperature, humidity, pressure for both pods
BH1750 I2C 0x23 Illuminance in lux
MLX90615 I2C / SMBus 0x5A Non-contact leaf temperature
DS18B20 x2 1-Wire ROM IDs from .env Soil temperature
INA219 I2C 0x40 Pod 1 hardware bus voltage and current
USB Camera V4L2 /dev/video0, fswebcam High-resolution plant photos

Project Structure

.
|-- docker-compose.yml
|-- docker-compose.mock.yml
|-- Dockerfile
|-- docs/
|-- requirements.txt
|-- requirements-hardware.txt
|-- scripts/
|-- .env.example
|-- data/
|-- src/
|   |-- main.py
|   |-- config.py
|   |-- sensors/
|   |-- network/
|   `-- utils/
`-- tests/

Configuration

Copy .env.example to .env and adjust values for the target environment.

Important variables:

MQTT publishes one JSON payload per tick to:

{MQTT_TOPIC_PREFIX}/{DEVICE_ID}/telemetry

Planned maintenance lifecycle events publish to:

{MQTT_TOPIC_PREFIX}/{DEVICE_ID}/events

Payload Shape

Telemetry payloads use schema version senior-pomidor.edge.telemetry.v2:

{
  "schema_version": "senior-pomidor.edge.telemetry.v2",
  "device_id": "balcony-edge-01",
  "timestamp_utc": "2026-06-06T10:00:00Z",
  "pods": {
    "pod_1": {
      "metrics": {
        "adc_raw": 12450.0,
        "soil_moisture_percent": 45.0,
        "soil_temperature_c": 22.4,
        "air_temperature_c": 24.5,
        "air_humidity_percent": 58.2,
        "air_pressure_hpa": 1008.3,
        "light_lux": 18000.0,
        "ir_ambient_temp_c": 24.8,
        "leaf_temp_c": 25.1
      },
      "errors": []
    }
  },
  "system_health": {
    "rpi_core": {
      "cpu_temp_c": 56.4,
      "wifi_rssi_dbm": -68.0,
      "disk_usage_percent": 34.2,
      "disk_free_percent": 65.8,
      "disk_total_bytes": 32000000000,
      "disk_used_bytes": 10944000000,
      "disk_free_bytes": 21056000000,
      "filesystem_read_only": false,
      "telemetry_buffer_file_count": 3,
      "telemetry_buffer_size_bytes": 12288,
      "photo_buffer_file_count": 2,
      "photo_buffer_size_bytes": 2400000,
      "recent_io_error_count": 0,
      "io_wait_percent": 1.7
    },
    "pod_1_hardware": {
      "bus_voltage_v": 3.25,
      "bus_current_ma": 12.4
    },
    "errors": []
  }
}

Plant sensor errors are reported in each pod’s errors array so partial telemetry can still be delivered. Hardware health probe errors are reported in system_health.errors; failed health metrics or subtrees are omitted while the rest of the health payload remains available.

If Pod 2 is not connected yet, set this in .env:

POD2_ENABLED=false

The main loop will skip all Pod 2 sensors. The payload will still include pods.pod_2, but it will be marked as disabled with empty metrics and errors:

{
  "enabled": false,
  "metrics": {},
  "errors": []
}

The Raspberry Pi setup script can set this for you:

./scripts/setup_raspberry_pi.sh --hardware --mqtt-host 192.168.1.10 --pod2-disabled

Local Storage

Every telemetry payload is saved locally before network delivery. By default, Docker stores files on the Raspberry Pi host at:

./data/telemetry

Retention is controlled by:

LOCAL_STORAGE_DIR=data/telemetry
LOCAL_EVENT_DIR=data/events
LOCAL_STORAGE_MAX_AGE_DAYS=30
LOCAL_STORAGE_MAX_SIZE_MB=256

Cleanup runs after each saved payload. Files older than the configured age are removed first; if the directory still exceeds the configured size, the oldest remaining files are removed until the directory is below the limit.

Planned Maintenance Events

Use explicit lifecycle events when intentionally shutting down the Raspberry Pi edge node for sensor service or planned maintenance. Before stopping the container or powering down the Pi, run:

python scripts/maintenance_event.py start --reason "sensor service"

After the Pi and edge service are back, run:

python scripts/maintenance_event.py complete --reason "sensor service"

The event payload uses schema version senior-pomidor.edge.event.v1 and includes event_id, device_id, event_type, timestamp_utc, source, and optional reason. Supported event types are maintenance_started and maintenance_completed.

If the MQTT broker or Core server is unavailable, the event is queued locally under LOCAL_EVENT_DIR and retried the next time the maintenance event command runs.

Camera photos are saved locally before upload. By default, Docker stores them on the Raspberry Pi host at:

./data/photos

Each accepted photo is written as a JPEG with a JSON sidecar containing photo_id, device_id, captured_at_utc, file size, sharpness score, attempts, and upload status. Photo cleanup uses the same age and size limits as telemetry local storage.

Photo Upload

Photo bytes are not sent over MQTT or embedded in telemetry payloads. The recommended Core server receive method is an HTTP multipart endpoint because photos are large binary payloads and should not share the telemetry topic.

Set:

PHOTO_UPLOAD_ENABLED=true
PHOTO_UPLOAD_URL=http://192.168.1.10:8000/api/v1/edge/photos
PHOTO_UPLOAD_TOKEN=optional-bearer-token

The edge node sends pending photos oldest-first with:

The Core server should treat photo_id as an idempotency key and return any 2xx status after accepting the file. On upload failure, the photo remains local with upload_status=pending and is retried on a later camera cycle.

Local Development

Install common, cross-platform dependencies on Windows or Linux:

python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt

Linux shell equivalent:

python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt

Run tests:

pytest -q

Run the code quality harness locally:

python -m pip install -r requirements-dev.txt
ruff format --check .
ruff check .
mypy src
pip-audit --cache-dir .cache/pip-audit -r requirements.txt
pip-audit --cache-dir .cache/pip-audit -r requirements-hardware.txt

Shell, Docker, and secret hygiene checks used by CI:

shellcheck scripts/setup_raspberry_pi.sh
docker compose config
docker compose -f docker-compose.mock.yml config
hadolint Dockerfile
gitleaks detect --source . --no-git

Run a single mock telemetry tick on Windows PowerShell:

$env:MQTT_HOST = "localhost"
$env:MOCK_SENSORS = "true"
$env:MAX_TICKS = "1"
python -m src.main

Run a single mock telemetry tick on Linux:

MQTT_HOST=localhost MOCK_SENSORS=true MAX_TICKS=1 python -m src.main

Raspberry Pi Hardware Setup

The Raspberry Pi setup can be automated from the repository root:

chmod +x scripts/setup_raspberry_pi.sh
./scripts/setup_raspberry_pi.sh --hardware

The script installs host packages, installs Docker if needed, enables I2C and 1-Wire, creates .env from .env.example, sets MOCK_SENSORS=false, builds the image, and starts the hardware container. It also installs USB camera tooling (fswebcam and v4l-utils).

Operations runbooks:

If the script enables I2C or 1-Wire, it will stop and ask for a reboot. Reboot and run the same command again:

sudo reboot
cd ~/apps/senior-pomidor-plant-v2
./scripts/setup_raspberry_pi.sh --hardware

For a fully unattended first pass, allow automatic reboot:

./scripts/setup_raspberry_pi.sh --hardware --auto-reboot

You can also preseed the most important .env values in the same command:

./scripts/setup_raspberry_pi.sh \
  --hardware \
  --mqtt-host 192.168.1.10 \
  --device-id balcony-edge-01 \
  --pod1-rom 28-000000000001 \
  --pod2-rom 28-000000000002 \
  --interval 60 \
  --auto-reboot

Mock mode on Raspberry Pi uses the same setup script without hardware passthrough:

./scripts/setup_raspberry_pi.sh --mock

Review .env after the first run and set the real MQTT server address, DS18B20 ROM IDs, health-control address values, and calibration values before relying on real telemetry.

Before enabling camera capture in the edge node, verify the camera directly on the Raspberry Pi:

fswebcam --device /dev/video0 --resolution 1920x1080 --jpeg 95 --no-banner --skip 5 test.jpg

Then set:

CAMERA_ENABLED=true
CAMERA_INTERVAL_SECONDS=3600
CAMERA_DEVICE=/dev/video0
CAMERA_RESOLUTION=1920x1080

Hardware Discovery and Troubleshooting

Test only selected sensors without starting MQTT, storage, camera capture, or the main application loop:

docker compose build senior-pomidor-edge
docker compose run --rm --no-deps senior-pomidor-edge \
  python scripts/test_sensors.py bme280 bh1750 --repeat 3 --interval 1

Use all to test every configured sensor, or list the available names:

docker compose run --rm --no-deps senior-pomidor-edge python scripts/test_sensors.py --list
docker compose run --rm --no-deps senior-pomidor-edge python scripts/test_sensors.py all

The command reads sensor addresses, ADS1115 calibration, DS18B20 ROM IDs, and MOCK_SENSORS from .env. Each result is marked ok or error, and the command exits with status 1 if any selected sensor fails. Add --mock to verify the command without hardware.

Start with a single hardware tick and the latest saved telemetry file. This shows both numeric values and isolated sensor errors:

docker compose logs -f senior-pomidor-edge
ls -lt data/telemetry | head
cat data/telemetry/<latest-file>.json

In mock mode the readings are fixed example values. If real sensors are connected, confirm .env contains:

MOCK_SENSORS=false

After changing .env, restart the container:

docker compose up --build -d

Find Sensor IDs and Addresses

I2C sensors share /dev/i2c-1. Detect them on the Raspberry Pi host:

sudo i2cdetect -y 1

Expected addresses:

Device Expected address .env setting
ADS1115 0x48 ADS1115_ADDRESS=0x48
BME280 0x76 BME280_ADDRESS=0x76
BH1750 0x23 BH1750_ADDRESS=0x23
MLX90615 / MLX90614-compatible 0x5A MLX90615_ADDRESS=0x5A
INA219 0x40 INA219_ADDRESS=0x40

DS18B20 sensors are 1-Wire devices and expose ROM IDs under /sys/bus/w1/devices:

ls /sys/bus/w1/devices/
ls /sys/bus/w1/devices/28-*

Use the full 28-... directory name in .env:

DS18B20_POD1_ROM=28-000000000001
DS18B20_POD2_ROM=28-000000000002

USB cameras usually appear as /dev/video*:

v4l2-ctl --list-devices
ls -l /dev/video*

Set the selected device:

CAMERA_DEVICE=/dev/video0

Wi-Fi health uses the host interface name:

iwconfig
cat /proc/net/wireless

Set it if your Raspberry Pi does not use wlan0:

WIFI_INTERFACE=wlan0

If a Sensor Is Not Detected

If Values Look Wrong

Reading Error Fields

Sensor failures are non-fatal. Plant sensor errors appear under each pod:

"errors": [
  { "sensor": "bme280", "message": "timeout" }
]

System health errors appear under system_health.errors:

"system_health": {
  "errors": [
    { "sensor": "rpi_wifi_rssi", "message": "RSSI for interface wlan0 is unavailable" }
  ]
}

When an error appears, fix the named sensor first, then run one telemetry tick again and inspect the newest JSON file.

Docker

Cross-platform mock container:

docker compose -f docker-compose.mock.yml up --build

The mock compose file installs only requirements.txt, so it does not need Raspberry Pi sensor libraries.

Raspberry Pi hardware container:

cp .env.example .env
docker compose up --build -d

The hardware compose file can be parsed without .env, but the app still requires real MQTT and sensor configuration at runtime. It installs requirements-hardware.txt, including rpi-lgpio for the RPi.GPIO compatibility module on Raspberry Pi OS Bookworm, persists telemetry and photos to ./data, and is Linux/Raspberry Pi specific because it passes through hardware host paths. Camera-enabled Docker deployments use /dev/video0 by default; the setup script installs fswebcam, v4l-utils, libgpiod2, and wireless-tools, and the compose file runs privileged with host networking plus /run/udev mounted for Raspberry Pi hardware and Wi-Fi RSSI access.