refactor(fusion_iot): port sensor taxonomy + dashboards, retire fusion_plating_sensors
fusion_plating_sensors had broader scope (sensor_type taxonomy,
dashboards, location flexibility) but its core logic was ALL
scaffolding — alert rules stored thresholds with zero side effects,
measurement create just filled a name sequence, the HTTP endpoint
required user-auth session cookies. Meanwhile fusion_plating_iot had
the actual working alerting: in-spec checks, quality-hold auto-raise
with excursion dedupe, setpoint + deviation, token-auth ingest for
headless hardware. Plus 563 real readings from the pilot Pi.
Right unification: keep fusion_plating_iot (working) as the base,
port the valuable structural bits from fusion_plating_sensors, demolish
the latter entirely.
**Ported to fusion_plating_iot:**
- `fp.sensor.type` — taxonomy model with 8 seeded types (Temperature,
pH, Conductivity, Level, Pressure, Flow, Concentration, Switch).
Richer than the device_kind Selection; hardware-independent (one
"Temperature" type covers DS18B20 / PT100 / thermocouple).
- `fp.sensor.dashboard` — named grouping of sensors with
out-of-spec count. Simple but useful ("ENP Line 1 — all tanks")
without the broken alert-rule complexity.
- Extended `fp.tank.sensor`:
* `uuid` (stable logical ID, survives hardware swaps)
* `sensor_type_id` (link to the taxonomy above)
* `work_center_id`, `facility_id`, `location_name` — alternatives to
tank_id so probes can live on ovens, ambient air, effluent pipes
without faking a "tank"
* `effective_location` computed — picks the first non-empty of the
four location fields for display
**Post-install hook** backfills UUID + default sensor_type on existing
live sensors. Verified on the 2 pilot sensors: both got UUIDs, both
auto-assigned the Temperature type via device_kind=ds18b20 mapping.
**Deleted** (all of fusion_plating_sensors, 1205 LOC):
- fp.sensor (replaced by fp.tank.sensor with added fields)
- fp.sensor.measurement (replaced by fp.tank.reading)
- fp.sensor.alert.rule (replaced by inline alert_min/max + working hold)
- /fp/sensor/measure controller (replaced by /fp/iot/ingest)
- fp.sensor.measure.wizard (not needed — Odoo's normal create form works)
- The "Sensors" submenu hierarchy (Dashboards/All Sensors/Measurements/
Sensor Types) that created the dup menus the user reported
**Menu now**: Plating → Operations → Sensors
- Dashboards (fp.sensor.dashboard)
- Sensors (fp.tank.sensor — renamed from "Tank Sensors" since
it supports non-tank locations now)
- Readings (fp.tank.reading)
- Sensor Types (fp.sensor.type)
No data loss: all 591 Pi readings preserved (up from 563 earlier as
the live poller kept running throughout the refactor). Brief 503 on
the Pi during the Odoo module-update restart; poller auto-retried on
the next 30s tick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_sensor_type
|
||||
from . import fp_sensor_dashboard
|
||||
from . import fp_tank_sensor
|
||||
from . import fp_tank_reading
|
||||
from . import fusion_plating_tank
|
||||
|
||||
71
fusion_iot/fusion_plating_iot/models/fp_sensor_dashboard.py
Normal file
71
fusion_iot/fusion_plating_iot/models/fp_sensor_dashboard.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Sensor dashboard — a named group of related sensors.
|
||||
|
||||
Use case: a shop wants one "ENP Line — Tanks 1-4" view that aggregates
|
||||
temperature, pH, and level probes from four bath tanks into a single
|
||||
trending chart + alert count. A dashboard is just a logical grouping;
|
||||
actual rendering happens in the UI.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpSensorDashboard(models.Model):
|
||||
_name = 'fp.sensor.dashboard'
|
||||
_description = 'Fusion Plating — Sensor Dashboard'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(string='Name', required=True, tracking=True)
|
||||
description = fields.Text(string='Description')
|
||||
sensor_ids = fields.Many2many(
|
||||
'fp.tank.sensor',
|
||||
'fp_sensor_dashboard_rel',
|
||||
'dashboard_id', 'sensor_id',
|
||||
string='Sensors',
|
||||
)
|
||||
sensor_count = fields.Integer(
|
||||
string='Members', compute='_compute_counts',
|
||||
)
|
||||
out_of_spec_count = fields.Integer(
|
||||
string='Sensors Out of Spec', compute='_compute_counts',
|
||||
help='How many of this dashboard\'s sensors currently have a '
|
||||
'last-reading that\'s out of spec.',
|
||||
)
|
||||
color = fields.Integer(string='Colour Tag')
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.depends('sensor_ids.last_reading_in_spec', 'sensor_ids.last_reading_at')
|
||||
def _compute_counts(self):
|
||||
for dash in self:
|
||||
dash.sensor_count = len(dash.sensor_ids)
|
||||
dash.out_of_spec_count = len(dash.sensor_ids.filtered(
|
||||
lambda s: s.last_reading_at and not s.last_reading_in_spec
|
||||
))
|
||||
|
||||
def action_view_sensors(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Sensors — {self.name}',
|
||||
'res_model': 'fp.tank.sensor',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.sensor_ids.ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_recent_readings(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Readings — {self.name}',
|
||||
'res_model': 'fp.tank.reading',
|
||||
'view_mode': 'graph,list,form',
|
||||
'domain': [('sensor_id', 'in', self.sensor_ids.ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
83
fusion_iot/fusion_plating_iot/models/fp_sensor_type.py
Normal file
83
fusion_iot/fusion_plating_iot/models/fp_sensor_type.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Sensor-type taxonomy.
|
||||
|
||||
A richer taxonomy than the `device_kind` Selection on fp.tank.sensor.
|
||||
Types describe WHAT the sensor measures (temperature, pH, conductivity,
|
||||
level, etc.) plus the data type of its readings. Same type can back
|
||||
multiple hardware models — e.g. "Temperature" covers DS18B20, PT100,
|
||||
PT1000, thermocouple.
|
||||
|
||||
Seeded defaults ship with the module (see data/fp_sensor_type_data.xml)
|
||||
so users don't have to set them up from scratch. Admins add more via
|
||||
Plating → Operations → Sensors → Sensor Types.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpSensorType(models.Model):
|
||||
_name = 'fp.sensor.type'
|
||||
_description = 'Fusion Plating — Sensor Type'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
code = fields.Char(
|
||||
string='Code', required=True,
|
||||
help='Short machine-friendly identifier, e.g. "temperature", "ph".',
|
||||
)
|
||||
measurement_type = fields.Selection(
|
||||
[
|
||||
('number', 'Number'),
|
||||
('boolean', 'Boolean'),
|
||||
('text', 'Text'),
|
||||
],
|
||||
string='Measurement Type', required=True, default='number',
|
||||
help='Most plating-shop sensors are numeric. Boolean is useful for '
|
||||
'limit switches / float switches; text for things like batch '
|
||||
'lot codes from a barcode reader.',
|
||||
)
|
||||
default_uom = fields.Char(
|
||||
string='Default Unit',
|
||||
help='Unit string (°C, pH, µS/cm, etc.) inherited by sensors of '
|
||||
'this type unless they carry a parameter-specific unit.',
|
||||
)
|
||||
icon = fields.Char(
|
||||
string='Icon',
|
||||
help='Optional fa-* or oi-* icon name for use in dashboards.',
|
||||
)
|
||||
sensor_ids = fields.One2many(
|
||||
'fp.tank.sensor', 'sensor_type_id',
|
||||
string='Sensors of this type',
|
||||
)
|
||||
sensor_count = fields.Integer(
|
||||
string='Sensor Count', compute='_compute_sensor_count',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_sensor_type_code_uniq', 'unique(code, company_id)',
|
||||
'Sensor-type code must be unique per company.'),
|
||||
]
|
||||
|
||||
def _compute_sensor_count(self):
|
||||
for rec in self:
|
||||
rec.sensor_count = len(rec.sensor_ids)
|
||||
|
||||
def action_view_sensors(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Sensors — {self.name}',
|
||||
'res_model': 'fp.tank.sensor',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sensor_type_id', '=', self.id)],
|
||||
'context': {'default_sensor_type_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -25,6 +25,22 @@ class FpTankSensor(models.Model):
|
||||
string='Sensor Name', required=True,
|
||||
help='Human label (e.g. "Tank 3 — ENP temp").',
|
||||
)
|
||||
uuid = fields.Char(
|
||||
string='UUID',
|
||||
copy=False, readonly=True, index=True,
|
||||
help='Stable logical identifier — survives hardware swaps. If a '
|
||||
'probe dies and gets replaced, keep the UUID, change the '
|
||||
'device_serial. Every measurement tied to this UUID remains '
|
||||
'part of the same logical history.',
|
||||
)
|
||||
sensor_type_id = fields.Many2one(
|
||||
'fp.sensor.type',
|
||||
string='Sensor Type',
|
||||
help='Taxonomy — temperature, pH, conductivity, etc. '
|
||||
'Independent from hardware (a "temperature" sensor could be '
|
||||
'a DS18B20, PT100, or thermocouple; device_kind captures '
|
||||
'the hardware, sensor_type_id captures the role).',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -57,7 +73,11 @@ class FpTankSensor(models.Model):
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Where this sensor lives + what it measures
|
||||
# Where this sensor lives — can be ANY of tank / work_center /
|
||||
# facility / named-location. Keep tank_id as the primary (most
|
||||
# sensors are bath-mounted) but allow the other three as
|
||||
# alternatives so we can mount probes on ovens, ambient air,
|
||||
# waste-water discharge, etc. without forcing a fake "tank".
|
||||
# ------------------------------------------------------------------
|
||||
tank_id = fields.Many2one(
|
||||
'fusion.plating.tank', string='Tank', ondelete='cascade',
|
||||
@@ -67,6 +87,22 @@ class FpTankSensor(models.Model):
|
||||
help='Optional — if the sensor is bound to a specific bath '
|
||||
'chemistry rather than a physical tank.',
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center', string='Work Centre',
|
||||
help='Alternative to tank_id — use when the sensor is attached '
|
||||
'to a station, oven, or line rather than a bath tank.',
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility', string='Facility',
|
||||
help='Alternative to tank_id / work_center_id — use for '
|
||||
'facility-wide sensors (ambient, HVAC, perimeter).',
|
||||
)
|
||||
location_name = fields.Char(
|
||||
string='Location (free-text)',
|
||||
help='Free-text override when none of the above fit — e.g. '
|
||||
'"North bay wall", "Effluent pipe exit", "Rinse tank #2 '
|
||||
'roof". Shown alongside the structured location in views.',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter Measured',
|
||||
required=True,
|
||||
@@ -74,6 +110,27 @@ class FpTankSensor(models.Model):
|
||||
'etc.). Drives unit labelling + out-of-spec alerting against '
|
||||
'the parameter\'s target_min / target_max.',
|
||||
)
|
||||
effective_location = fields.Char(
|
||||
string='Location',
|
||||
compute='_compute_effective_location', store=True,
|
||||
help='Display string for the sensor location — prefers tank, '
|
||||
'then work_center, then facility, then free-text.',
|
||||
)
|
||||
|
||||
@api.depends('tank_id', 'bath_id', 'work_center_id', 'facility_id',
|
||||
'location_name')
|
||||
def _compute_effective_location(self):
|
||||
for rec in self:
|
||||
if rec.tank_id:
|
||||
rec.effective_location = rec.tank_id.name
|
||||
elif rec.work_center_id:
|
||||
rec.effective_location = rec.work_center_id.name
|
||||
elif rec.facility_id:
|
||||
rec.effective_location = rec.facility_id.name
|
||||
elif rec.location_name:
|
||||
rec.effective_location = rec.location_name
|
||||
else:
|
||||
rec.effective_location = ''
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Target / alerting behaviour — three concepts:
|
||||
@@ -185,8 +242,19 @@ class FpTankSensor(models.Model):
|
||||
('fp_tank_sensor_serial_uniq',
|
||||
'unique(device_serial)',
|
||||
'Each hardware serial can only be mapped to one sensor.'),
|
||||
('fp_tank_sensor_uuid_uniq',
|
||||
'unique(uuid)',
|
||||
'UUID must be unique across all sensors.'),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
import uuid as _uuid
|
||||
for vals in vals_list:
|
||||
if not vals.get('uuid'):
|
||||
vals['uuid'] = _uuid.uuid4().hex
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('reading_ids')
|
||||
def _compute_reading_count(self):
|
||||
for rec in self:
|
||||
|
||||
Reference in New Issue
Block a user