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>
113 lines
4.6 KiB
Python
113 lines
4.6 KiB
Python
"""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.')
|