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:
gsinghpal
2026-04-20 14:59:20 -04:00
parent 8a841e7534
commit 19a49acba0
3 changed files with 233 additions and 0 deletions

View 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

View 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()

View File

@@ -0,0 +1,79 @@
"""One-shot setup for the live Pi probe.
Does three things:
1. Rotates fusion_plating_iot.ingest_token to a real random secret.
2. Picks (or creates) a test tank + temperature parameter.
3. Creates the fp.tank.sensor record mapped to the real probe serial
(28-000000b276e4), with plating-realistic alert thresholds.
Run:
cat fp_iot_setup_live_sensor.py | odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
Prints the new token at the end — copy it into the Pi poller script.
"""
import secrets
env = env # noqa — odoo shell
SERIAL = '28-000000b276e4'
SENSOR_NAME = 'Pi IoT pilot — DS18B20 #1'
# -----------------------------------------------------------
# 1. Rotate token
# -----------------------------------------------------------
new_token = f'fp-iot-{secrets.token_urlsafe(24)}'
env['ir.config_parameter'].sudo().set_param(
'fusion_plating_iot.ingest_token', new_token,
)
print(f'\n>>> Token rotated to: {new_token}')
print(' (use this in the Pi poller X-FP-IOT-Token header)\n')
# -----------------------------------------------------------
# 2. Pick / create test tank + temperature parameter
# -----------------------------------------------------------
tank = env['fusion.plating.tank'].search(
[('code', '=', 'SMOKE-TEST')], limit=1,
) or env['fusion.plating.tank'].search([], limit=1)
if not tank:
facility = env['fusion.plating.facility'].search([], limit=1)
tank = env['fusion.plating.tank'].create({
'name': 'IoT Pilot Tank',
'code': 'IOT-PILOT-01',
'facility_id': facility.id if facility else False,
})
print(f'Tank: {tank.name} (id={tank.id}, code={tank.code})')
param = env['fusion.plating.bath.parameter'].search(
[('parameter_type', '=', 'temperature')], limit=1,
) or env['fusion.plating.bath.parameter'].search([], limit=1)
print(f'Parameter: {param.name} '
f'(target range {param.target_min}..{param.target_max} {param.uom or ""})')
# -----------------------------------------------------------
# 3. Create / update fp.tank.sensor
# -----------------------------------------------------------
# Alert thresholds chosen for bench testing — 15°C to 35°C will stay
# in-spec at room temp, goes out if you squeeze the probe.
sensor = env['fp.tank.sensor'].search([('device_serial', '=', SERIAL)], limit=1)
vals = {
'name': SENSOR_NAME,
'device_serial': SERIAL,
'device_kind': 'ds18b20',
'tank_id': tank.id,
'parameter_id': param.id,
'alert_min_override': 15.0,
'alert_max_override': 35.0,
'alert_on_out_of_spec': True,
}
if sensor:
sensor.write(vals)
print(f'Updated existing sensor id={sensor.id}')
else:
sensor = env['fp.tank.sensor'].create(vals)
print(f'Created sensor id={sensor.id}')
print(f' alert range: {sensor.alert_min_override}°C .. {sensor.alert_max_override}°C')
env.cr.commit()
print('\n✓ Setup committed.\n')
print('Next step — deploy the Pi poller with token:')
print(f' {new_token}')