docs(iot): Sub 7 design spec — per-sensor polling interval + entech seed

Per-sensor override on fp.tank.sensor.poll_interval_minutes, company-
wide default on res.company, ingest controller rate-limits readings
that arrive inside the effective interval. Seeds entech with 25
tanks (5 small, 20 big — 10 active/10 inactive) and 50 sensors
(temp + pH per tank) as noupdate=1 data so admin edits survive
upgrades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 23:20:29 -04:00
parent 25c3f6f8d1
commit def9c801fa

View File

@@ -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: "610 active tanks need continuous monitoring, polling 1530 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 #15`), all active.
- 20 big tanks (`Big Tank #120`); 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 `<field name="facility_id" search="[]"/>` 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.<whatever>`` → 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.*