From 19a49acba0bbf23faf80ab43609bc269ec5b8c91 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 20 Apr 2026 14:59:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(fusion=5Fiot):=20live=20DS18B20=20poller?= =?UTF-8?q?=20for=20Pi-side=20=E2=80=94=20first=20real=20tank=20reading=20?= =?UTF-8?q?in=20Odoo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_iot/pi/fp-iot-poller.service | 25 ++++ fusion_iot/pi/fp_iot_poller.py | 129 ++++++++++++++++++ .../scripts/fp_iot_setup_live_sensor.py | 79 +++++++++++ 3 files changed, 233 insertions(+) create mode 100644 fusion_iot/pi/fp-iot-poller.service create mode 100644 fusion_iot/pi/fp_iot_poller.py create mode 100644 fusion_iot/scripts/fp_iot_setup_live_sensor.py diff --git a/fusion_iot/pi/fp-iot-poller.service b/fusion_iot/pi/fp-iot-poller.service new file mode 100644 index 00000000..604400f6 --- /dev/null +++ b/fusion_iot/pi/fp-iot-poller.service @@ -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 diff --git a/fusion_iot/pi/fp_iot_poller.py b/fusion_iot/pi/fp_iot_poller.py new file mode 100644 index 00000000..6a9cd45d --- /dev/null +++ b/fusion_iot/pi/fp_iot_poller.py @@ -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=" + _, _, 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() diff --git a/fusion_iot/scripts/fp_iot_setup_live_sensor.py b/fusion_iot/scripts/fp_iot_setup_live_sensor.py new file mode 100644 index 00000000..a656f53e --- /dev/null +++ b/fusion_iot/scripts/fp_iot_setup_live_sensor.py @@ -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}')