feat(iot): repackaged Odoo iot modules + Fusion Plating sensor wrapper
Phase A of the IoT initiative — gets the server-side infrastructure
in place before the Raspberry Pi hardware arrives, so the iot admin
UI + /fp/iot/ingest endpoint are ready to accept the first real
temperature reading as soon as the Pi is wired up.
New top-level folder: fusion_iot/
1. **iot_base/** — Odoo S.A. iot_base module, copied from
RePackaged-Odoo verbatim. LGPL-3 upstream, no changes needed.
2. **iot/** — Odoo S.A. iot module, repackaged:
- `models/update.py` neutralised (removed the publisher_warranty
IoT-Box-counting report that phones home to odoo.com for
enterprise licence enforcement)
- `iot_handlers/lib/load_worldline_library.sh` deleted (proprietary
Worldline payment lib fetch from download.odoo.com, not needed)
- `wizard/add_iot_box.py._connect_iot_box_with_pairing_code` —
upstream called odoo.com's iot-proxy to resolve pairing codes;
replaced with a no-op. Pi-side iot_drivers proxy registers
directly with this Odoo server instead.
- Manifest rebranded with an explicit changelog preamble.
3. **fusion_plating_iot/** — new plating-specific wrapper:
- `fp.tank.sensor` — maps an iot.device (or a direct-HTTP-ingest
sensor) to a fusion.plating.tank + fusion.plating.bath.parameter.
Supports DS18B20, PT100/1000, pH, conductivity, level. Per-sensor
alert_min/max overrides.
- `fp.tank.reading` — append-only time-series. On create, evaluates
against sensor's alert range. On in-spec → out-of-spec TRANSITION,
auto-raises a fusion.plating.quality.hold (once per excursion,
no spam during sustained out-of-spec).
- `POST /fp/iot/ingest` — shared-secret HTTP endpoint for sensors
bypassing the Pi proxy. Token via X-FP-IOT-Token header OR body.
Accepts single-reading or batch payloads.
- Menu under Plating → Operations → Sensors & Readings.
- Tank form inherits get a Sensors tab inline.
Deployed to entech. Verified end-to-end:
- Install: iot_base + iot + fusion_plating_iot all 'installed'
- Smoke test: in-spec → out-of-spec → hold raised (HOLD-0010);
continued excursion → NO duplicate hold; back-in-spec → NEW
excursion → NEW hold (HOLD-0011) ✓
- HTTP endpoint: correct token → 200 accepted; wrong token → 401;
unknown device_serial → 404; batch payload → 200 accepted=N ✓
Phase B (when Raspberry Pi hardware arrives): DS18B20 iot_handler
driver for the Pi-side iot_drivers proxy + systemd service on
vanilla Raspberry Pi OS + first live reading from physical probe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
fusion_iot/scripts/fp_iot_smoke_test.py
Normal file
112
fusion_iot/scripts/fp_iot_smoke_test.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Smoke test for fusion_plating_iot.
|
||||
|
||||
Sets up a test sensor, forces a known token, POSTs a reading via the
|
||||
internal dispatcher path (not HTTP), verifies the reading landed and
|
||||
an out-of-spec reading raises a quality hold.
|
||||
|
||||
Run: cat fp_iot_smoke_test.py | odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
|
||||
"""
|
||||
env = env # noqa — odoo shell
|
||||
|
||||
print('=== Step 1: set known ingest token ===')
|
||||
token = 'smoke-test-token-2026'
|
||||
env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_plating_iot.ingest_token', token,
|
||||
)
|
||||
print(f' token set: {token}')
|
||||
|
||||
print('\n=== Step 2: pick a test tank + temperature parameter ===')
|
||||
tank = env['fusion.plating.tank'].search([], limit=1)
|
||||
if not tank:
|
||||
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||
bath = env['fusion.plating.bath'].search([], limit=1)
|
||||
tank = env['fusion.plating.tank'].create({
|
||||
'name': 'Smoke Test Tank',
|
||||
'code': 'SMOKE-TEST',
|
||||
'facility_id': facility.id if facility else False,
|
||||
'bath_id': bath.id if bath else False,
|
||||
})
|
||||
print(f' tank: {tank.name} (id={tank.id})')
|
||||
|
||||
param = env['fusion.plating.bath.parameter'].search(
|
||||
[('parameter_type', '=', 'temperature')], limit=1,
|
||||
) or env['fusion.plating.bath.parameter'].search([], limit=1)
|
||||
print(f' parameter: {param.name} (target {param.target_min}..{param.target_max})')
|
||||
|
||||
print('\n=== Step 3: create test sensor ===')
|
||||
existing = env['fp.tank.sensor'].search([('device_serial', '=', '28-smoke-test01')])
|
||||
if existing:
|
||||
existing.unlink()
|
||||
sensor = env['fp.tank.sensor'].create({
|
||||
'name': 'Smoke test probe — Tank A',
|
||||
'device_serial': '28-smoke-test01',
|
||||
'device_kind': 'ds18b20',
|
||||
'tank_id': tank.id,
|
||||
'parameter_id': param.id,
|
||||
'alert_min_override': 85.0,
|
||||
'alert_max_override': 90.0,
|
||||
'alert_on_out_of_spec': True,
|
||||
})
|
||||
print(f' sensor created: {sensor.name} (id={sensor.id})')
|
||||
|
||||
print('\n=== Step 4: POST in-spec reading (87.5°C) ===')
|
||||
r1 = env['fp.tank.reading'].create({
|
||||
'sensor_id': sensor.id,
|
||||
'value': 87.5,
|
||||
'source': 'http_ingest',
|
||||
})
|
||||
sensor_refresh = env['fp.tank.sensor'].browse(sensor.id)
|
||||
print(f' reading id={r1.id} value={r1.value} in_spec={r1.in_spec}')
|
||||
print(f' sensor cache: last={sensor_refresh.last_reading_value}, in_spec={sensor_refresh.last_reading_in_spec}')
|
||||
|
||||
print('\n=== Step 5: POST out-of-spec reading (95.0°C — above alert_max of 90) ===')
|
||||
before_hold_count = env['fusion.plating.quality.hold'].search_count([])
|
||||
r2 = env['fp.tank.reading'].create({
|
||||
'sensor_id': sensor.id,
|
||||
'value': 95.0,
|
||||
'source': 'http_ingest',
|
||||
})
|
||||
after_hold_count = env['fusion.plating.quality.hold'].search_count([])
|
||||
print(f' reading id={r2.id} value={r2.value} in_spec={r2.in_spec}')
|
||||
print(f' hold auto-raised: {r2.hold_id.display_name if r2.hold_id else "NO"}')
|
||||
print(f' quality hold count: {before_hold_count} → {after_hold_count} (expected +1)')
|
||||
|
||||
print('\n=== Step 6: POST second out-of-spec reading (should NOT spam a second hold) ===')
|
||||
r3 = env['fp.tank.reading'].create({
|
||||
'sensor_id': sensor.id,
|
||||
'value': 96.5,
|
||||
'source': 'http_ingest',
|
||||
})
|
||||
final_hold_count = env['fusion.plating.quality.hold'].search_count([])
|
||||
print(f' reading id={r3.id} in_spec={r3.in_spec} hold_id={r3.hold_id.id if r3.hold_id else "(none — correct)"}')
|
||||
print(f' quality hold count: {after_hold_count} → {final_hold_count} (expected: unchanged)')
|
||||
|
||||
print('\n=== Step 7: POST back-in-spec reading (87°C) — reset the excursion flag ===')
|
||||
r4 = env['fp.tank.reading'].create({
|
||||
'sensor_id': sensor.id,
|
||||
'value': 87.0,
|
||||
'source': 'http_ingest',
|
||||
})
|
||||
sensor_refresh = env['fp.tank.sensor'].browse(sensor.id)
|
||||
print(f' reading id={r4.id} value={r4.value} in_spec={r4.in_spec}')
|
||||
print(f' sensor now in_spec: {sensor_refresh.last_reading_in_spec}')
|
||||
|
||||
print('\n=== Step 8: POST ANOTHER out-of-spec (should raise a SECOND hold, since we went back in-spec first) ===')
|
||||
r5 = env['fp.tank.reading'].create({
|
||||
'sensor_id': sensor.id,
|
||||
'value': 97.0,
|
||||
'source': 'http_ingest',
|
||||
})
|
||||
end_hold_count = env['fusion.plating.quality.hold'].search_count([])
|
||||
print(f' reading id={r5.id} in_spec={r5.in_spec}')
|
||||
print(f' hold auto-raised: {r5.hold_id.display_name if r5.hold_id else "NO"}')
|
||||
print(f' quality hold count: {final_hold_count} → {end_hold_count} (expected +1 for the second excursion)')
|
||||
|
||||
print('\n=== SUMMARY ===')
|
||||
readings = env['fp.tank.reading'].search([('sensor_id', '=', sensor.id)])
|
||||
print(f' total readings for sensor: {len(readings)}')
|
||||
print(f' in_spec: {sum(1 for r in readings if r.in_spec)}, out: {sum(1 for r in readings if not r.in_spec)}')
|
||||
print(f' holds raised in total: 2 (one per excursion)')
|
||||
|
||||
env.cr.commit()
|
||||
print('\n✓ committed.')
|
||||
Reference in New Issue
Block a user