feat(iot): Sub 7 — per-sensor polling interval + rate-limit + entech seed

Per-sensor override on fp.tank.sensor.poll_interval_minutes with a
company-wide default (res.company.x_fc_default_poll_interval_minutes,
default 30) exposed in Settings → Fusion Plating → IoT. Single
lookup helper _fp_effective_poll_interval_minutes keeps downstream
call sites simple. Read-only poll_interval_display Char ("30 min
(default)" / "15 min (override)") keeps units unambiguous per user
request.

Ingest endpoint /fp/iot/ingest drops readings that arrive inside a
sensor's effective interval, returning {accepted, skipped} so the Pi
agent can log it. Pi-side interval stays its own concern.

Post-init hook seeds 5 small tanks + 20 big tanks (10 active, 10
inactive) with 1 temperature + 1 pH sensor each → 25 tanks, 50
sensors. Idempotent (keyed by tank.code, with_context(active_test=
False)). Opt-in via ir.config_parameter
fusion_plating_iot.seed_entech_tanks = '1' so a fresh install
elsewhere doesn't auto-seed. Flag set on entech today; 27 tanks / 52
sensors now live (2 pilot + 25 seeded).

Smoke on entech: 14/14 assertions pass including idempotency and
rate-limit conditions.

fusion_plating_iot → 19.0.2.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 23:29:08 -04:00
parent def9c801fa
commit a7fd39d6f3
11 changed files with 358 additions and 5 deletions

View File

@@ -31,7 +31,7 @@ Returns 200 + `{ok: true, accepted: N}` on success, 401 on auth fail,
import hmac
import json
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from odoo import http
from odoo.http import request, Response
@@ -109,6 +109,7 @@ class FpIotIngestController(http.Controller):
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()
@@ -122,6 +123,23 @@ class FpIotIngestController(http.Controller):
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 1530 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,
@@ -130,10 +148,11 @@ class FpIotIngestController(http.Controller):
})
accepted += 1
status = 200 if accepted else (404 if unknown_serials else 400)
status = 200 if (accepted or skipped_interval) else (404 if unknown_serials else 400)
payload = {
'ok': accepted > 0,
'ok': (accepted + skipped_interval) > 0,
'accepted': accepted,
'skipped': skipped_interval,
}
if unknown_serials:
payload['unknown_serials'] = unknown_serials