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

@@ -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 ===')