diff --git a/fusion_iot/fusion_plating_iot/__manifest__.py b/fusion_iot/fusion_plating_iot/__manifest__.py
index 93833cf6..1185aa76 100644
--- a/fusion_iot/fusion_plating_iot/__manifest__.py
+++ b/fusion_iot/fusion_plating_iot/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — IoT Integration',
- 'version': '19.0.1.0.0',
+ 'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Wire physical tank sensors to Fusion Plating — live '
'temperature / chemistry readings with auto quality holds '
@@ -51,6 +51,7 @@ Part of the Fusion Plating product family by Nexa Systems Inc.
'views/fp_tank_sensor_views.xml',
'views/fp_tank_reading_views.xml',
'views/fusion_plating_tank_views.xml',
+ 'views/res_config_settings_views.xml',
'views/fp_iot_menu.xml',
],
'post_init_hook': 'post_init_hook',
diff --git a/fusion_iot/fusion_plating_iot/controllers/fp_iot_ingest.py b/fusion_iot/fusion_plating_iot/controllers/fp_iot_ingest.py
index f210dcdb..9ceff9fc 100644
--- a/fusion_iot/fusion_plating_iot/controllers/fp_iot_ingest.py
+++ b/fusion_iot/fusion_plating_iot/controllers/fp_iot_ingest.py
@@ -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 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,
@@ -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
diff --git a/fusion_iot/fusion_plating_iot/hooks.py b/fusion_iot/fusion_plating_iot/hooks.py
index d3637d2a..e8eb536f 100644
--- a/fusion_iot/fusion_plating_iot/hooks.py
+++ b/fusion_iot/fusion_plating_iot/hooks.py
@@ -23,6 +23,7 @@ _logger = logging.getLogger(__name__)
def post_init_hook(env):
_backfill_uuids(env)
_backfill_sensor_types(env)
+ _seed_entech_tanks_and_sensors(env)
def _backfill_uuids(env):
@@ -74,3 +75,98 @@ def _backfill_sensor_types(env):
updated += 1
_logger.info('fp.tank.sensor: set default sensor_type_id on %d records',
updated)
+
+
+def _seed_entech_tanks_and_sensors(env):
+ """Sub 7 — seed 25 tanks (5 small + 20 big; 10 big inactive) with
+ one temperature and one pH sensor each.
+
+ Idempotent: tanks keyed by `code` so re-runs skip existing rows.
+ Sensors keyed by (tank_id, parameter_type) so duplicates aren't
+ created if the admin added a temp/pH sensor manually after seed.
+
+ Opt-in via system parameter `fusion_plating_iot.seed_entech_tanks`.
+ Default False so a fresh install doesn't auto-seed on anyone else.
+ Admin sets it to '1' via Settings → Technical → System Parameters
+ and re-runs the module upgrade (-u fusion_plating_iot) to trigger.
+ """
+ Param = env['ir.config_parameter'].sudo()
+ if Param.get_param('fusion_plating_iot.seed_entech_tanks', '0') != '1':
+ return
+
+ # with_context(active_test=False) so the idempotency search sees
+ # the inactive big tanks and doesn't try to recreate them on re-run.
+ Tank = env['fusion.plating.tank'].with_context(active_test=False)
+ Sensor = env['fp.tank.sensor'].with_context(active_test=False)
+ Parameter = env['fusion.plating.bath.parameter']
+ Facility = env['fusion.plating.facility']
+
+ facility = Facility.search([], limit=1)
+ if not facility:
+ _logger.warning('Sub 7 seed: no fusion.plating.facility found — '
+ 'skipping tank seed. Create a facility first.')
+ return
+
+ temp_param = (
+ Parameter.search([('code', '=', 'TEMP')], limit=1)
+ or Parameter.search([('parameter_type', '=', 'temperature')], limit=1)
+ )
+ ph_param = (
+ Parameter.search([('code', '=', 'PH')], limit=1)
+ or Parameter.search([('parameter_type', '=', 'ph')], limit=1)
+ )
+ if not temp_param or not ph_param:
+ _logger.warning('Sub 7 seed: temperature / pH bath parameters '
+ 'not found — skipping. Seed bath parameters first.')
+ return
+
+ plan = []
+ for i in range(1, 6):
+ plan.append({'name': 'Small Tank #%d' % i, 'code': 'SMALL-%02d' % i,
+ 'active': True, 'sequence': 10 + i})
+ for i in range(1, 21):
+ plan.append({'name': 'Big Tank #%d' % i, 'code': 'BIG-%02d' % i,
+ 'active': (i <= 10), 'sequence': 20 + i})
+
+ tanks_created = 0
+ sensors_created = 0
+ for row in plan:
+ tank = Tank.search([('code', '=', row['code'])], limit=1)
+ if not tank:
+ tank = Tank.create({
+ 'name': row['name'],
+ 'code': row['code'],
+ 'facility_id': facility.id,
+ 'sequence': row['sequence'],
+ 'active': row['active'],
+ })
+ tanks_created += 1
+ # Temperature sensor
+ if not Sensor.search_count([
+ ('tank_id', '=', tank.id),
+ ('parameter_id.parameter_type', '=', 'temperature'),
+ ]):
+ Sensor.create({
+ 'name': '%s — Temperature' % tank.name,
+ 'tank_id': tank.id,
+ 'parameter_id': temp_param.id,
+ 'device_kind': 'ds18b20',
+ 'active': row['active'],
+ })
+ sensors_created += 1
+ # pH sensor
+ if not Sensor.search_count([
+ ('tank_id', '=', tank.id),
+ ('parameter_id.parameter_type', '=', 'ph'),
+ ]):
+ Sensor.create({
+ 'name': '%s — pH' % tank.name,
+ 'tank_id': tank.id,
+ 'parameter_id': ph_param.id,
+ 'device_kind': 'ph',
+ 'active': row['active'],
+ })
+ sensors_created += 1
+
+ _logger.info('Sub 7 seed: created %d tanks + %d sensors',
+ tanks_created, sensors_created)
diff --git a/fusion_iot/fusion_plating_iot/models/__init__.py b/fusion_iot/fusion_plating_iot/models/__init__.py
index 79ccae27..d0f59f6b 100644
--- a/fusion_iot/fusion_plating_iot/models/__init__.py
+++ b/fusion_iot/fusion_plating_iot/models/__init__.py
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from . import fp_sensor_type
from . import fp_sensor_dashboard
+from . import res_company
+from . import res_config_settings
from . import fp_tank_sensor
from . import fp_tank_reading
from . import fusion_plating_tank
diff --git a/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py b/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py
index ffa9c5e2..1007e8ed 100644
--- a/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py
+++ b/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py
@@ -163,6 +163,53 @@ class FpTankSensor(models.Model):
'specific sensor.',
)
+ # ------------------------------------------------------------------
+ # Sub 7 — per-sensor polling interval
+ #
+ # Blank on the sensor = inherit res.company default. The effective
+ # value gates the ingest endpoint so too-frequent readings are
+ # dropped even when a Pi agent polls at a shorter cadence.
+ # ------------------------------------------------------------------
+ poll_interval_minutes = fields.Integer(
+ string='Polling Interval (minutes)',
+ help='How often a reading from this sensor should be stored. '
+ 'Leave blank to inherit the company-wide default. Readings '
+ 'that arrive inside this interval are dropped by the '
+ 'ingest endpoint — the database stays clean even if the '
+ 'Pi agent polls more often.',
+ )
+ poll_interval_display = fields.Char(
+ string='Effective Interval',
+ compute='_compute_poll_interval_display',
+ help='Human-readable form of the sensor\'s effective polling '
+ 'interval. Shows "(override)" when the sensor carries its '
+ 'own value, "(default)" when it inherits.',
+ )
+
+ @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
+
+ def _fp_effective_poll_interval_minutes(self):
+ """Return the effective polling interval for this sensor.
+
+ Single lookup point. Call this rather than reading the raw
+ field so later additions (per-tank override in Sub 8, per-
+ customer override in Sub 6) only touch this helper.
+ """
+ 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
+
# ------------------------------------------------------------------
# Effective target — resolves override → parameter default → 0
# ------------------------------------------------------------------
diff --git a/fusion_iot/fusion_plating_iot/models/res_company.py b/fusion_iot/fusion_plating_iot/models/res_company.py
new file mode 100644
index 00000000..0c958cc0
--- /dev/null
+++ b/fusion_iot/fusion_plating_iot/models/res_company.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 7 — company-wide default for the IoT sensor polling interval.
+
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ 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. Used by the ingest endpoint to drop '
+ 'readings that arrive inside the interval, keeping the '
+ 'database clean even when a Pi agent polls more often.',
+ )
diff --git a/fusion_iot/fusion_plating_iot/models/res_config_settings.py b/fusion_iot/fusion_plating_iot/models/res_config_settings.py
new file mode 100644
index 00000000..698ec5fa
--- /dev/null
+++ b/fusion_iot/fusion_plating_iot/models/res_config_settings.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 7 — Expose the IoT polling default on the Fusion Plating
+# Settings page so admins manage it alongside other plating settings.
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ x_fc_default_poll_interval_minutes = fields.Integer(
+ related='company_id.x_fc_default_poll_interval_minutes',
+ readonly=False,
+ string='IoT default polling interval (minutes)',
+ )
diff --git a/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml b/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml
index e388c128..2652c1fa 100644
--- a/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml
+++ b/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml
@@ -30,6 +30,8 @@
+
+
@@ -110,6 +112,22 @@
+
+
+
+
+
+
+
+
+ Leave the interval blank to inherit the
+ company-wide default (configured under
+ Settings → Fusion Plating → IoT). Readings
+ arriving faster than the effective interval
+ are dropped by the ingest endpoint.
+
+
+
diff --git a/fusion_iot/fusion_plating_iot/views/res_config_settings_views.xml b/fusion_iot/fusion_plating_iot/views/res_config_settings_views.xml
new file mode 100644
index 00000000..e5a994e5
--- /dev/null
+++ b/fusion_iot/fusion_plating_iot/views/res_config_settings_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ res.config.settings.view.form.fp.iot
+ res.config.settings
+
+
+
+
+
+
+ minutes
+
+
+
+
+
+
+
diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md
index 59bdc5f8..56260a1c 100644
--- a/fusion_plating/CLAUDE.md
+++ b/fusion_plating/CLAUDE.md
@@ -372,7 +372,7 @@ rewrite code as new requirements surface. Each sub-project has its own design do
| 4 | Contract Review (optional, per-part, settings-driven QA roster, QA-005 1:1 PDF) | **Shipped 2026-04-22** | 2i |
| 5 | Order-line fields (fp.serial registry, auto job#, coating-scoped thickness dropdown, revision picker) | **Shipped 2026-04-22** | 5, 6, Q2 |
| 6 | Contact Profiles & Communication Routing (sub-contacts + per-location notification lists + global contacts) | Pending | client transcript A/B/C |
-| 7 | IoT tuning (configurable polling interval 15–30 min, seed 6–10 tank sensors) | Pending | client transcript D |
+| 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D |
| 8 | Receiving / Inspection / QC flow restructure (split receiving vs inspection; racking crew inspects, not receiver) | Pending | client transcript E |
| ∞ | First-off / last-off QC | Deferred | client transcript F |
| ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G |
diff --git a/fusion_plating/docs/superpowers/tests/2026-04-22-sub7-smoke.py b/fusion_plating/docs/superpowers/tests/2026-04-22-sub7-smoke.py
new file mode 100644
index 00000000..69057c7f
--- /dev/null
+++ b/fusion_plating/docs/superpowers/tests/2026-04-22-sub7-smoke.py
@@ -0,0 +1,91 @@
+"""Sub 7 smoke test — runs inside odoo-shell on entech."""
+env = env
+from datetime import datetime, timedelta
+
+Param = env['ir.config_parameter'].sudo()
+Sensor = env['fp.tank.sensor']
+Tank = env['fusion.plating.tank']
+Reading = env['fp.tank.reading']
+
+# ---- Field presence + helper -----------------------------------------
+assert 'poll_interval_minutes' in Sensor._fields
+assert 'poll_interval_display' in Sensor._fields
+assert 'x_fc_default_poll_interval_minutes' in env['res.company']._fields
+print('[OK] Fields present')
+
+default = env.company.x_fc_default_poll_interval_minutes
+assert default == 30, f'default should be 30, got {default}'
+print(f'[OK] Company default polling = {default} min')
+
+# Pick any existing sensor
+any_sensor = Sensor.search([], limit=1)
+assert any_sensor
+assert any_sensor._fp_effective_poll_interval_minutes() == 30
+print(f'[OK] Helper returns default for blank sensor')
+
+any_sensor.poll_interval_minutes = 5
+any_sensor.invalidate_recordset()
+assert any_sensor._fp_effective_poll_interval_minutes() == 5
+assert '(override)' in any_sensor.poll_interval_display
+print(f'[OK] Override respected: {any_sensor.poll_interval_display}')
+
+any_sensor.poll_interval_minutes = 0
+any_sensor.invalidate_recordset()
+assert '(default)' in any_sensor.poll_interval_display
+print(f'[OK] Blank interval shows default: {any_sensor.poll_interval_display}')
+
+# ---- Seed the 25 tanks + 50 sensors via the hook ----------------------
+TankAll = Tank.with_context(active_test=False)
+SensorAll = Sensor.with_context(active_test=False)
+tanks_before = TankAll.search_count([])
+sensors_before = SensorAll.search_count([])
+Param.set_param('fusion_plating_iot.seed_entech_tanks', '1')
+from odoo.addons.fusion_plating_iot.hooks import _seed_entech_tanks_and_sensors
+_seed_entech_tanks_and_sensors(env)
+tanks_after = TankAll.search_count([])
+sensors_after = SensorAll.search_count([])
+print(f'[OK] Seed ran. Tanks: {tanks_before} → {tanks_after} (+{tanks_after - tanks_before})')
+print(f'[OK] Seed ran. Sensors: {sensors_before} → {sensors_after} (+{sensors_after - sensors_before})')
+assert tanks_after - tanks_before == 25, 'expected +25 tanks'
+assert sensors_after - sensors_before == 50, 'expected +50 sensors'
+
+# Active / inactive breakdown
+small_active = TankAll.search_count([('code', 'like', 'SMALL-%'), ('active', '=', True)])
+big_active = TankAll.search_count([('code', 'like', 'BIG-%'), ('active', '=', True)])
+big_inactive = TankAll.search_count([('code', 'like', 'BIG-%'), ('active', '=', False)])
+assert small_active == 5, f'expected 5 small active, got {small_active}'
+assert big_active == 10, f'expected 10 big active, got {big_active}'
+assert big_inactive == 10, f'expected 10 big inactive, got {big_inactive}'
+print(f'[OK] Active/Inactive split: small={small_active}, big_active={big_active}, big_inactive={big_inactive}')
+
+# Per-tank sensor counts
+sample = TankAll.search([('code', '=', 'BIG-01')], limit=1)
+sample_sensors = SensorAll.search([('tank_id', '=', sample.id)])
+types = sample_sensors.mapped('parameter_id.parameter_type')
+assert 'temperature' in types and 'ph' in types
+print(f'[OK] Big Tank #1 has sensors for: {types}')
+
+# Idempotency — re-run seed, counts don't change
+_seed_entech_tanks_and_sensors(env)
+assert TankAll.search_count([]) == tanks_after, 'seed not idempotent (tanks)'
+assert SensorAll.search_count([]) == sensors_after, 'seed not idempotent (sensors)'
+print('[OK] Seed is idempotent')
+
+# ---- Rate-limit: simulate two readings too close together -----------
+rate_sensor = Sensor.search([('code', '!=', False)], limit=1) if False else any_sensor
+rate_sensor.poll_interval_minutes = 30
+rate_sensor.last_reading_at = datetime.now() - timedelta(minutes=5) # 5 min ago
+print(f'[OK] Setup: interval=30min, last_reading=5min ago')
+# The ingest controller does the rate-limit. Simulate its check here.
+effective = rate_sensor._fp_effective_poll_interval_minutes()
+elapsed = datetime.now() - rate_sensor.last_reading_at
+assert elapsed < timedelta(minutes=effective), 'rate-limit check should fire'
+print('[OK] Rate-limit condition holds — reading would be skipped')
+
+rate_sensor.last_reading_at = datetime.now() - timedelta(minutes=35) # 35 min ago
+elapsed = datetime.now() - rate_sensor.last_reading_at
+assert elapsed >= timedelta(minutes=effective), 'rate-limit should allow after interval'
+print('[OK] Post-interval condition holds — reading would be stored')
+
+env.cr.rollback()
+print('\n=== SUB 7 SMOKE PASS — all assertions held ===')