feat(fusion_iot): live DS18B20 poller for Pi-side — first real tank reading in Odoo
Phase B kickoff — Pi hardware is wired up and posting readings to Odoo via /fp/iot/ingest every 30 seconds. No more simulations; this is real tank-temperature data. New files: - `pi/fp_iot_poller.py` — tiny systemd daemon. Reads every DS18B20 under /sys/bus/w1/devices/28-* (kernel CRC-validated) and POSTs a batch to /fp/iot/ingest with the shared-secret token. Handles transient network failures by logging + retrying on the next 30-second tick. Config in /etc/fp-iot/poller.conf (ODOO_URL, INGEST_TOKEN, INTERVAL_SECONDS). - `pi/fp-iot-poller.service` — systemd unit with hardened sandbox (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, ReadOnlyPaths=/sys/bus/w1 /etc/fp-iot). Auto-starts on boot, restarts on failure. - `scripts/fp_iot_setup_live_sensor.py` — one-shot entech initialiser: rotates the ingest token to a real random secret, picks a test tank + temperature parameter, creates the fp.tank.sensor record for serial 28-000000b276e4 with 15-35°C alert thresholds sized for bench testing. Verified end-to-end: Pi reads probe → posts to Odoo → reading lands in fp.tank.reading within 1s. 5 consecutive readings at 30s cadence show smooth temperature trend (probe cooling from 27.25°C to 26.06°C after being handled). in_spec flag correct, sensor cache (last_reading_value / _at / _in_spec) updates on every reading. Not yet done — Phase B continued: - Repackaged iot_drivers path (full Odoo IoT integration vs this simple HTTP path) — this poller is the minimal viable pilot. - Multi-probe (scalable to N probes per Pi; code already supports, just need more hardware). - Graduate to the proper iot.device + iot.box Odoo registration flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
fusion_iot/pi/fp-iot-poller.service
Normal file
25
fusion_iot/pi/fp-iot-poller.service
Normal file
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=Fusion Plating IoT — DS18B20 poller
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/fp_iot_poller.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=fp
|
||||
Group=fp
|
||||
# Poller only needs read access to /sys/bus/w1 + /etc/fp-iot; everything
|
||||
# else locked down.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadOnlyPaths=/sys/bus/w1 /etc/fp-iot
|
||||
# Journal-only logging — use `journalctl -u fp-iot-poller -f` to tail
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
129
fusion_iot/pi/fp_iot_poller.py
Normal file
129
fusion_iot/pi/fp_iot_poller.py
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fusion Plating IoT — DS18B20 poller.
|
||||
|
||||
Polls every DS18B20 probe the kernel exposes under /sys/bus/w1/devices/28-*
|
||||
and POSTs each reading to the configured Odoo instance. Runs forever under
|
||||
systemd; reads a config file at /etc/fp-iot/poller.conf.
|
||||
|
||||
Config file format:
|
||||
ODOO_URL=http://10.200.1.26:8069
|
||||
INGEST_TOKEN=fp-iot-XXXXXXXXXXXXX
|
||||
INTERVAL_SECONDS=30
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
CONFIG_PATH = '/etc/fp-iot/poller.conf'
|
||||
LOG = logging.getLogger('fp-iot-poller')
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
cfg = {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, _, val = line.partition('=')
|
||||
cfg[key.strip()] = val.strip()
|
||||
except FileNotFoundError:
|
||||
LOG.error('Config file not found: %s', path)
|
||||
sys.exit(1)
|
||||
for required in ('ODOO_URL', 'INGEST_TOKEN'):
|
||||
if not cfg.get(required):
|
||||
LOG.error('Missing required config key: %s', required)
|
||||
sys.exit(1)
|
||||
cfg.setdefault('INTERVAL_SECONDS', '30')
|
||||
return cfg
|
||||
|
||||
|
||||
def read_probe(path: str) -> float | None:
|
||||
"""Read one DS18B20 sysfs file. Returns Celsius or None on failure."""
|
||||
try:
|
||||
with open(os.path.join(path, 'w1_slave')) as f:
|
||||
data = f.read()
|
||||
# First line ends in "YES" for CRC-valid reads
|
||||
lines = data.strip().split('\n')
|
||||
if len(lines) < 2 or not lines[0].rstrip().endswith('YES'):
|
||||
LOG.warning('Probe %s bad CRC: %r', path, data)
|
||||
return None
|
||||
# Second line has "t=<millicelsius>"
|
||||
_, _, raw = lines[1].partition('t=')
|
||||
return float(raw) / 1000.0
|
||||
except Exception as e:
|
||||
LOG.warning('Probe read failed (%s): %s', path, e)
|
||||
return None
|
||||
|
||||
|
||||
def post_readings(odoo_url: str, token: str, readings: list[dict]) -> bool:
|
||||
"""POST batch to /fp/iot/ingest. Returns True on 2xx."""
|
||||
payload = json.dumps({'readings': readings}).encode()
|
||||
req = urllib.request.Request(
|
||||
odoo_url.rstrip('/') + '/fp/iot/ingest',
|
||||
data=payload,
|
||||
method='POST',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-FP-IOT-Token': token,
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode()
|
||||
if 200 <= resp.status < 300:
|
||||
LOG.info('Ingest OK (%d): %s', resp.status, body.strip())
|
||||
return True
|
||||
LOG.warning('Ingest %d: %s', resp.status, body.strip())
|
||||
return False
|
||||
except urllib.error.HTTPError as e:
|
||||
LOG.warning('Ingest HTTP %d: %s', e.code, e.read().decode()[:200])
|
||||
return False
|
||||
except Exception as e:
|
||||
LOG.warning('Ingest failed: %s', e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_config(CONFIG_PATH)
|
||||
odoo_url = cfg['ODOO_URL']
|
||||
token = cfg['INGEST_TOKEN']
|
||||
interval = int(cfg['INTERVAL_SECONDS'])
|
||||
LOG.info('Starting — polling every %ds, posting to %s', interval, odoo_url)
|
||||
|
||||
while True:
|
||||
probes = glob.glob('/sys/bus/w1/devices/28-*')
|
||||
if not probes:
|
||||
LOG.warning('No DS18B20 probes detected under /sys/bus/w1/devices/28-*')
|
||||
readings = []
|
||||
now = datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||
for probe_path in probes:
|
||||
serial = os.path.basename(probe_path)
|
||||
temp_c = read_probe(probe_path)
|
||||
if temp_c is None:
|
||||
continue
|
||||
readings.append({
|
||||
'device_serial': serial,
|
||||
'value': round(temp_c, 3),
|
||||
'read_at': now,
|
||||
})
|
||||
LOG.info('Probe %s = %.3f°C', serial, temp_c)
|
||||
if readings:
|
||||
post_readings(odoo_url, token, readings)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user