diff --git a/fusion_plating/docs/superpowers/specs/2026-04-22-sub7-iot-polling-design.md b/fusion_plating/docs/superpowers/specs/2026-04-22-sub7-iot-polling-design.md new file mode 100644 index 00000000..92dce491 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-04-22-sub7-iot-polling-design.md @@ -0,0 +1,206 @@ +# Sub 7 — IoT Polling Interval + Entech Tank Seed + +**Date:** 2026-04-22 +**Module scope:** `fusion_plating_iot` (primary — adds the poll interval field, rate-limit, seed data) +**Status:** Design approved; implementing in this session. +**Predecessor context:** Fine-Tuning Initiative, entry in `fusion_plating/CLAUDE.md` (Sub 7 +preview: "6–10 active tanks need continuous monitoring, polling 15–30 min, configurable +per sensor"). Client refined to 25-tank inventory on 2026-04-22. + +--- + +## 1. Scope + +### In scope + +1. **Per-sensor polling interval** on `fp.tank.sensor.poll_interval_minutes` (Integer, + optional). Blank inherits the company-wide default. +2. **Global default** on `res.company.x_fc_default_poll_interval_minutes` (Integer, + default 30), exposed through `res.config.settings`. +3. **Helper** `fp.tank.sensor._fp_effective_poll_interval_minutes()` returns the + effective interval for a sensor (own value > company default). Single lookup point + so Sub 6 / Sub 8 additions don't scatter logic. +4. **Ingest rate-limit** in `/fp/iot/ingest`: readings that arrive inside the sensor's + effective interval are dropped; endpoint returns a `skipped` count alongside + `stored`. +5. **Entech tank seed** (25 tanks, 50 sensors, `noupdate=1`): + - 5 small tanks (`Small Tank #1–5`), all active. + - 20 big tanks (`Big Tank #1–20`); first 10 active, last 10 `active=False`. + - Each tank → 1 temperature + 1 pH sensor. Inactive tank → inactive sensors. + - Sensors land with **no `device_serial`** — hookup pending Ethernet delivery. +6. **View exposure:** polling interval + display on sensor form / tree + settings block. + +### Out of scope + +- Pi-side poller changes (`/etc/fp-iot/poller.conf` stays its own concern; Odoo-side + rate-limit is the single source of truth for what actually lands in the DB). +- Scheduled polling job inside Odoo (sensors are push-driven). +- Dashboard / trend changes. +- Alerting on missed polls. + +--- + +## 2. Data Model + +### 2.1 `fp.tank.sensor` additions + +```python +poll_interval_minutes = fields.Integer( + string='Polling Interval (minutes)', + help='How often readings from this sensor should be stored. Leave ' + 'blank to inherit the company default. Incoming readings that ' + 'arrive faster than this interval are dropped by the ingest ' + 'endpoint so the log stays clean even if a polling agent is ' + 'set to a shorter cadence.', +) +poll_interval_display = fields.Char( + string='Effective Interval', + compute='_compute_poll_interval_display', + help='Human-readable form of the effective interval — "30 min " + '(default)" when blank, "15 min (override)" when set.', +) +``` + +### 2.2 `res.company` default + +```python +x_fc_default_poll_interval_minutes = fields.Integer( + string='IoT default polling interval (minutes)', + default=30, + help='Applied to any fp.tank.sensor that does not set its own ' + 'polling interval.', +) +``` + +### 2.3 `res.config.settings` mirror + +`x_fc_default_poll_interval_minutes = fields.Integer(related='company_id.x_fc_default_poll_interval_minutes', readonly=False)` + +### 2.4 Helper + compute + +```python +def _fp_effective_poll_interval_minutes(self): + self.ensure_one() + if self.poll_interval_minutes and self.poll_interval_minutes > 0: + return self.poll_interval_minutes + return (self.env.company.x_fc_default_poll_interval_minutes or 30) + +@api.depends('poll_interval_minutes') +def _compute_poll_interval_display(self): + default = self.env.company.x_fc_default_poll_interval_minutes or 30 + for rec in self: + if rec.poll_interval_minutes and rec.poll_interval_minutes > 0: + rec.poll_interval_display = _('%d min (override)') % rec.poll_interval_minutes + else: + rec.poll_interval_display = _('%d min (default)') % default +``` + +--- + +## 3. Ingest Rate-Limit + +In `fusion_plating_iot/controllers/fp_iot_ingest.py`, for each incoming reading: + +1. Resolve the `fp.tank.sensor` by `device_serial`. +2. If the sensor has a `last_reading_at` (either stored on the sensor or via the latest + `fp.tank.reading`) and `now - last_reading_at < effective_interval`, **skip** the reading. +3. Return JSON with `{'stored': n, 'skipped': m, 'errors': [...]}` so the Pi can log it. + +The sensor may already track a last-read stamp via `fp.tank.reading` — confirm during +implementation, and if not, add a `last_read_at` datetime on the sensor that gets +stamped on every stored reading. + +--- + +## 4. Seed Data + +File: `fusion_plating_iot/data/fp_entech_tank_seed.xml`, `noupdate="1"`. + +- Generate 25 tank records + 50 sensor records via inline XML. +- External IDs follow the pattern `tank_small_01..05`, `tank_big_01..20`, + `sensor_temp_small_01`, `sensor_ph_small_01`, etc. +- Tanks use the default facility via `` or reference + whatever facility seed exists on entech. Guard: if no facility exists, skip the seed (the + admin can rerun the seed after creating one). +- The 2 existing entech tanks (`TK-EN-01`, `TK-PASS-01`) are **untouched** — no rename, + no delete. They keep their existing sensors. + +--- + +## 5. Views + +- **fp.tank.sensor form:** `poll_interval_minutes` next to its display Char in a new group + "IoT Polling". +- **fp.tank.sensor tree:** both fields `optional="show"`. +- **fusion.plating.tank form:** the sensor O2M editor already exists (per the existing + inherit); include `poll_interval_display` as a read-only column so tank admins can eyeball + the cadence per sensor without opening each one. +- **Settings form:** new block under Fusion Plating section titled "IoT" with + `x_fc_default_poll_interval_minutes` + help text. + +--- + +## 6. Security & Migration + +- No security changes — `fp.tank.sensor` ACLs already cover the new field. +- No migration — new fields have defaults; seed data is `noupdate=1` and keyed by XML ID. +- `fusion_plating_iot` version bump — current ``19.0.`` → bumped one minor. + +--- + +## 7. Testing + +### 7.1 Smoke (entech odoo-shell) + +- Seed applied → 25 + 2 = 27 tanks present; 50 + 2 = 52 sensors present (existing entech + tanks unchanged). +- Sensor with blank interval → `_fp_effective_poll_interval_minutes()` returns company + default (30). +- Sensor with `poll_interval_minutes = 5` → helper returns 5, display reads "5 min (override)". +- Post two readings 10 s apart for a sensor with interval 30 → second is skipped. +- Post two readings 31 min apart → both stored. + +### 7.2 View render +- Open a seeded tank → both sensors visible with polling-interval display. +- Open settings → interval field present + editable. + +--- + +## 8. File Manifest + +``` +fusion_plating_iot/ +├── __manifest__.py (version bump + data entry for seed) +├── models/ +│ ├── __init__.py (+ res_company, res_config_settings) +│ ├── fp_tank_sensor.py (poll fields + helper) +│ ├── res_company.py NEW +│ └── res_config_settings.py NEW +├── controllers/ +│ └── fp_iot_ingest.py (rate-limit logic) +├── views/ +│ ├── fp_tank_sensor_views.xml (expose new fields) +│ └── res_config_settings_views.xml NEW (Fusion Plating → IoT block) +└── data/ + └── fp_entech_tank_seed.xml NEW +``` + +Rough LOC: ~120 Python, ~250 XML (mostly the seed). + +--- + +## 9. Rollout + +Update order on entech: +1. `fusion_plating_iot` — gets new fields, controller rate-limit, seed data. +2. Nothing else. + +Post-deploy verification: +- Tank list count = 27 +- Sensor list count = 52 +- Settings page shows IoT default = 30 +- Typing `5` into a sensor's interval updates display to "5 min (override)" after save + +--- + +*End of spec.*