# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. """Direct-HTTP ingest endpoint for sensors that bypass the Odoo IoT proxy. Authentication: shared secret header `X-FP-IOT-Token` compared to the system parameter `fusion_plating_iot.ingest_token`. The Pi proxy (via iot_drivers) uses Odoo's built-in websocket and doesn't need this path. Payload (JSON): { "device_serial": "28-abc123def456", "value": 87.3, "unit": "C", // informational, optional "read_at": "2026-04-19T13:12:05Z" // optional; defaults to now } Or a batch form: { "token": "", // alternative to X-FP-IOT-Token "readings": [ {"device_serial": "...", "value": ..., "read_at": "..."}, ... ] } Returns 200 + `{ok: true, accepted: N}` on success, 401 on auth fail, 404 if any device_serial isn't mapped to a fp.tank.sensor. """ import hmac import json import logging from datetime import datetime, timedelta, timezone from odoo import http from odoo.http import request, Response _logger = logging.getLogger(__name__) def _parse_read_at(raw): """Best-effort ISO-8601 parse — fall back to 'now' on garbage input.""" from odoo.fields import Datetime as OdooDatetime if not raw: return OdooDatetime.now() try: # Accept both "2026-04-19T13:12:05Z" and "2026-04-19 13:12:05" s = raw.replace('Z', '+00:00') dt = datetime.fromisoformat(s) # Strip tz to store naive UTC, which is what Odoo Datetime fields store if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc).replace(tzinfo=None) return dt except Exception: return OdooDatetime.now() class FpIotIngestController(http.Controller): @http.route('/fp/iot/ingest', type='http', auth='public', methods=['POST'], csrf=False, save_session=False) def ingest(self, **_kwargs): """Accept one-or-many sensor readings and land them in fp.tank.reading.""" # Pull the shared secret from config — configured at install via # data/ir_config_parameter_data.xml, but admins can rotate it # in Settings → Technical → System Parameters. expected = request.env['ir.config_parameter'].sudo().get_param( 'fusion_plating_iot.ingest_token', '' ) if not expected: _logger.warning('fp.iot.ingest: token not configured — all requests rejected') return Response( json.dumps({'ok': False, 'error': 'token_not_configured'}), status=503, content_type='application/json', ) # Accept token via either header or payload body — some simple # sensors can't easily set custom headers. header_token = request.httprequest.headers.get('X-FP-IOT-Token', '') raw = request.httprequest.get_data(as_text=True) or '' try: body = json.loads(raw) if raw else {} except ValueError: return Response( json.dumps({'ok': False, 'error': 'invalid_json'}), status=400, content_type='application/json', ) body_token = body.get('token', '') presented = header_token or body_token if not hmac.compare_digest(str(presented), str(expected)): return Response( json.dumps({'ok': False, 'error': 'unauthorised'}), status=401, content_type='application/json', ) # Normalise payload to a list of readings. readings = body.get('readings') if readings is None: # Single-reading shortcut if 'device_serial' in body and 'value' in body: readings = [body] else: return Response( json.dumps({'ok': False, 'error': 'no_readings'}), status=400, content_type='application/json', ) Sensor = request.env['fp.tank.sensor'].sudo() Reading = request.env['fp.tank.reading'].sudo() accepted = 0 skipped_interval = 0 unknown_serials = [] for r in readings: serial = (r.get('device_serial') or '').strip() if not serial: continue sensor = Sensor.search([('device_serial', '=', serial)], limit=1) if not sensor: unknown_serials.append(serial) continue try: value = float(r.get('value')) except (TypeError, ValueError): continue # Sub 7 — per-sensor rate-limit. Drop readings that arrive # inside the sensor's effective polling interval so the Pi # agent can happily poll every 30 s while the log only # retains a row every 15–30 min. Inactive sensors also # dropped so disabled tanks don't clutter the log. if not sensor.active: skipped_interval += 1 continue interval = sensor._fp_effective_poll_interval_minutes() if interval > 0 and sensor.last_reading_at: read_at = _parse_read_at(r.get('read_at')) elapsed = read_at - sensor.last_reading_at if elapsed < timedelta(minutes=interval): skipped_interval += 1 continue Reading.create({ 'sensor_id': sensor.id, 'value': value, 'reading_at': _parse_read_at(r.get('read_at')), 'source': 'http_ingest', }) accepted += 1 status = 200 if (accepted or skipped_interval) else (404 if unknown_serials else 400) payload = { 'ok': (accepted + skipped_interval) > 0, 'accepted': accepted, 'skipped': skipped_interval, } if unknown_serials: payload['unknown_serials'] = unknown_serials return Response( json.dumps(payload), status=status, content_type='application/json', )