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:
gsinghpal
2026-04-20 19:58:57 -04:00
parent 118f96dad4
commit cf205cfd11
34 changed files with 573 additions and 1224 deletions

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import controllers
from . import models
from . import wizard

View File

@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Sensors',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Tank and process sensor tracking with IoT API, dashboards, and alerts.',
'description': """
Fusion Plating — Sensors
========================
Chemistry and environmental sensor tracking for electroless nickel plating
and metal finishing operations. Replaces Steelhead Software sensor module.
* Sensor type definitions (pH, %, g/L, PPM, conductivity, etc.)
* Individual sensors linked to tanks, work centres, and facilities
* Timestamped measurements (manual entry + IoT API)
* Sensor dashboards with alert thresholds
* Quick-measure wizard for operators
* JSON-RPC endpoint for automated data collection
Part of the Fusion Plating product family by Nexa Systems Inc.
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
],
'data': [
'security/fp_sensor_security.xml',
'security/ir.model.access.csv',
'data/fp_sensor_sequence_data.xml',
'views/fp_sensor_type_views.xml',
'views/fp_sensor_views.xml',
'views/fp_sensor_measurement_views.xml',
'views/fp_sensor_dashboard_views.xml',
'views/fp_sensor_measure_wizard_views.xml',
'views/fp_sensor_menu.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import sensor_controller

View File

@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import http, fields
from odoo.http import request
_logger = logging.getLogger(__name__)
class SensorController(http.Controller):
"""JSON-RPC endpoint for IoT devices to push sensor readings."""
@http.route(
'/fp/sensor/measure',
type='jsonrpc',
auth='user',
methods=['POST'],
)
def sensor_measure(self, uuid=None, value=None, value_text=None,
value_bool=None, effective_at=None, comment=None):
"""Record a measurement from an IoT device or external API.
Args:
uuid: Sensor UUID (required)
value: Numeric reading (for NUMBER sensors)
value_text: Text reading (for TEXT sensors)
value_bool: Boolean reading (for BOOLEAN sensors)
effective_at: ISO datetime string (optional, defaults to now)
comment: Optional note
Returns:
dict with ok=True and measurement_id on success
"""
if not uuid:
return {'ok': False, 'error': 'uuid is required'}
sensor = request.env['fp.sensor'].sudo().search(
[('uuid', '=', uuid)], limit=1,
)
if not sensor:
return {'ok': False, 'error': f'No sensor with UUID {uuid}'}
vals = {
'sensor_id': sensor.id,
'source': 'api',
'creator_id': request.env.uid,
}
if effective_at:
vals['effective_at'] = effective_at
if comment:
vals['comment'] = comment
mtype = sensor.measurement_type
if mtype == 'number':
if value is None:
return {'ok': False, 'error': 'value is required for NUMBER sensors'}
vals['value'] = float(value)
elif mtype == 'text':
if value_text is None:
return {'ok': False, 'error': 'value_text is required for TEXT sensors'}
vals['value_text'] = str(value_text)
elif mtype == 'boolean':
if value_bool is None:
return {'ok': False, 'error': 'value_bool is required for BOOLEAN sensors'}
vals['value_bool'] = bool(value_bool)
measurement = request.env['fp.sensor.measurement'].sudo().create(vals)
_logger.info(
'Sensor %s (%s): recorded measurement %s via API',
sensor.name, uuid, measurement.name,
)
return {'ok': True, 'measurement_id': measurement.id}

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_fp_sensor_measurement" model="ir.sequence">
<field name="name">Sensor Measurement</field>
<field name="code">fp.sensor.measurement</field>
<field name="prefix">SMEAS/%(year)s/</field>
<field name="padding">5</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_sensor_type
from . import fp_sensor
from . import fp_sensor_measurement
from . import fp_sensor_dashboard
from . import fp_sensor_alert_rule

View File

@@ -1,167 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensor(models.Model):
"""Individual measurement point.
Each sensor represents a specific thing being measured at a specific
location, e.g. "Tank SP-7 pH" or "Waste Water Treatment pH".
Linked to a work centre (station) and/or tank for traceability.
UUID field enables IoT device integration.
"""
_name = 'fp.sensor'
_description = 'Fusion Plating — Sensor'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'name'
name = fields.Char(
string='Name',
required=True,
tracking=True,
help='Descriptive name, e.g. "Waste Water Treatment pH".',
)
uuid = fields.Char(
string='UUID',
index=True,
copy=False,
help='Hardware identifier for IoT devices.',
)
unit = fields.Char(
string='Unit',
help='Display unit for readings, e.g. "ph", "%", "g/L", "PPM", "L".',
)
sensor_type_id = fields.Many2one(
'fp.sensor.type',
string='Sensor Type',
required=True,
ondelete='restrict',
tracking=True,
)
measurement_type = fields.Selection(
related='sensor_type_id.measurement_type',
string='Measurement Type',
store=True,
readonly=True,
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Station',
ondelete='set null',
help='The work centre / station this sensor is attached to.',
)
tank_id = fields.Many2one(
'fusion.plating.tank',
string='Tank',
ondelete='set null',
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
ondelete='set null',
)
location_name = fields.Char(
string='Location',
help='Free-text location, e.g. "WaterTreatmentArea", "PLANT1.TankLine".',
)
use_location = fields.Boolean(
string='Use Location?',
default=False,
)
# -- Computed from latest measurement --
last_value = fields.Float(
string='Last Measurement',
compute='_compute_last_measurement',
store=True,
)
last_value_text = fields.Char(
string='Last Text Value',
compute='_compute_last_measurement',
store=True,
)
last_measured = fields.Datetime(
string='Last Measured',
compute='_compute_last_measurement',
store=True,
)
measurement_ids = fields.One2many(
'fp.sensor.measurement',
'sensor_id',
string='Measurements',
)
measurement_count = fields.Integer(
string='Measurement Count',
compute='_compute_measurement_count',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
_sql_constraints = [
('uuid_uniq', 'unique(uuid)',
'A sensor with this UUID already exists.'),
]
@api.depends(
'measurement_ids',
'measurement_ids.value',
'measurement_ids.value_text',
'measurement_ids.effective_at',
)
def _compute_last_measurement(self):
for sensor in self:
latest = self.env['fp.sensor.measurement'].search(
[('sensor_id', '=', sensor.id)],
order='effective_at desc, id desc',
limit=1,
)
if latest:
sensor.last_value = latest.value
sensor.last_value_text = latest.value_text
sensor.last_measured = latest.effective_at
else:
sensor.last_value = 0.0
sensor.last_value_text = False
sensor.last_measured = False
def action_quick_measure(self):
"""Open the quick measurement wizard."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Record Measurement',
'res_model': 'fp.sensor.measure.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_sensor_id': self.id},
}
def action_view_measurements(self):
"""Open measurement list filtered to this sensor."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Measurements — {self.name}',
'res_model': 'fp.sensor.measurement',
'view_mode': 'list,form',
'domain': [('sensor_id', '=', self.id)],
'context': {'default_sensor_id': self.id},
}
def _compute_measurement_count(self):
data = self.env['fp.sensor.measurement']._read_group(
[('sensor_id', 'in', self.ids)],
['sensor_id'],
['__count'],
)
mapped = {sensor.id: count for sensor, count in data}
for sensor in self:
sensor.measurement_count = mapped.get(sensor.id, 0)

View File

@@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class FpSensorAlertRule(models.Model):
"""Threshold alert rule for a sensor.
When a sensor's last reading exceeds threshold_high or falls
below threshold_low, the parent dashboard's alert_count increments.
"""
_name = 'fp.sensor.alert.rule'
_description = 'Fusion Plating — Sensor Alert Rule'
_order = 'sensor_id, id'
sensor_id = fields.Many2one(
'fp.sensor',
string='Sensor',
required=True,
ondelete='cascade',
)
dashboard_id = fields.Many2one(
'fp.sensor.dashboard',
string='Dashboard',
ondelete='cascade',
)
threshold_high = fields.Float(
string='High Threshold',
help='Alert when sensor value exceeds this.',
)
threshold_low = fields.Float(
string='Low Threshold',
help='Alert when sensor value falls below this.',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensorDashboard(models.Model):
"""Sensor chart grouping with alert monitoring.
Groups multiple sensors into a named dashboard for trend
visualization and threshold alerting.
"""
_name = 'fp.sensor.dashboard'
_description = 'Fusion Plating — Sensor Dashboard'
_inherit = ['mail.thread']
_order = 'name'
name = fields.Char(string='Name', required=True, tracking=True)
sensor_ids = fields.Many2many(
'fp.sensor',
'fp_sensor_dashboard_sensor_rel',
'dashboard_id',
'sensor_id',
string='Sensors',
)
alert_rule_ids = fields.One2many(
'fp.sensor.alert.rule',
'dashboard_id',
string='Alert Rules',
)
member_count = fields.Integer(
string='Members',
compute='_compute_counts',
)
alert_count = fields.Integer(
string='Alerts',
compute='_compute_counts',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
@api.depends('sensor_ids', 'alert_rule_ids', 'alert_rule_ids.active')
def _compute_counts(self):
for dash in self:
dash.member_count = len(dash.sensor_ids)
active_rules = dash.alert_rule_ids.filtered('active')
alert_count = 0
for rule in active_rules:
sensor = rule.sensor_id
val = sensor.last_value
if rule.threshold_high and val > rule.threshold_high:
alert_count += 1
elif rule.threshold_low and val < rule.threshold_low:
alert_count += 1
dash.alert_count = alert_count

View File

@@ -1,97 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensorMeasurement(models.Model):
"""Timestamped sensor reading.
High-volume model — no mail.thread to keep writes fast for IoT
ingestion. Three value columns cover all measurement types;
views branch on the sensor's measurement_type.
"""
_name = 'fp.sensor.measurement'
_description = 'Fusion Plating — Sensor Measurement'
_order = 'effective_at desc, id desc'
name = fields.Char(
string='Reference',
readonly=True,
copy=False,
default='New',
)
sensor_id = fields.Many2one(
'fp.sensor',
string='Sensor',
required=True,
ondelete='cascade',
index=True,
)
# Denormalised for list filtering / grouping
sensor_type_id = fields.Many2one(
related='sensor_id.sensor_type_id',
store=True,
readonly=True,
)
measurement_type = fields.Selection(
related='sensor_id.measurement_type',
store=True,
readonly=True,
)
unit = fields.Char(
related='sensor_id.unit',
readonly=True,
)
# -- Value columns (one per measurement type) --
value = fields.Float(
string='Value',
help='Numeric measurement (for NUMBER type sensors).',
)
value_text = fields.Char(
string='Text Value',
help='Text measurement (for TEXT type sensors).',
)
value_bool = fields.Boolean(
string='Boolean Value',
help='Boolean measurement (for BOOLEAN type sensors).',
)
comment = fields.Text(string='Comment')
effective_at = fields.Datetime(
string='Effective At',
default=fields.Datetime.now,
required=True,
index=True,
help='Timestamp of when the measurement was actually taken.',
)
source = fields.Selection(
[
('manual', 'Manual'),
('api', 'API'),
('iot', 'IoT Device'),
],
string='Source',
default='manual',
required=True,
)
creator_id = fields.Many2one(
'res.users',
string='Creator',
default=lambda self: self.env.uid,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
@api.model_create_multi
def create(self, vals_list):
seq = self.env['ir.sequence']
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = seq.next_by_code('fp.sensor.measurement') or 'New'
return super().create(vals_list)

View File

@@ -1,65 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class FpSensorType(models.Model):
"""Sensor measurement template.
Defines what a sensor measures (pH, % Nickel Activity, conductivity, etc.)
and the data type of its readings. The unit lives on the sensor itself
because the same type (e.g. NUMBER) can have different units (ph, %, g/L).
"""
_name = 'fp.sensor.type'
_description = 'Fusion Plating — Sensor Type'
_order = 'name'
name = fields.Char(
string='Name',
required=True,
help='Descriptive name, e.g. "Waste Water Effluent pH", '
'"Tank SP-7 % Nickel Activity".',
)
measurement_type = fields.Selection(
[
('number', 'Number'),
('boolean', 'Boolean'),
('text', 'Text'),
],
string='Measurement Type',
required=True,
default='number',
)
sensor_ids = fields.One2many(
'fp.sensor',
'sensor_type_id',
string='Sensors',
)
sensor_count = fields.Integer(
string='Sensor Count',
compute='_compute_sensor_count',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
def _compute_sensor_count(self):
for rec in self:
rec.sensor_count = len(rec.sensor_ids)
def action_view_sensors(self):
"""Open sensor list filtered to this type."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Sensors — {self.name}',
'res_model': 'fp.sensor',
'view_mode': 'list,form',
'domain': [('sensor_type_id', '=', self.id)],
'context': {'default_sensor_type_id': self.id},
}

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Multi-company isolation rules -->
<record id="fp_sensor_type_company_rule" model="ir.rule">
<field name="name">Sensor Type: multi-company</field>
<field name="model_id" ref="model_fp_sensor_type"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_company_rule" model="ir.rule">
<field name="name">Sensor: multi-company</field>
<field name="model_id" ref="model_fp_sensor"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_measurement_company_rule" model="ir.rule">
<field name="name">Sensor Measurement: multi-company</field>
<field name="model_id" ref="model_fp_sensor_measurement"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_dashboard_company_rule" model="ir.rule">
<field name="name">Sensor Dashboard: multi-company</field>
<field name="model_id" ref="model_fp_sensor_dashboard"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_alert_rule_company_rule" model="ir.rule">
<field name="name">Sensor Alert Rule: multi-company</field>
<field name="model_id" ref="model_fp_sensor_alert_rule"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
</data>
</odoo>

View File

@@ -1,17 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_sensor_type_operator,fp.sensor.type.operator,model_fp_sensor_type,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_type_supervisor,fp.sensor.type.supervisor,model_fp_sensor_type,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_type_manager,fp.sensor.type.manager,model_fp_sensor_type,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_operator,fp.sensor.operator,model_fp_sensor,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_supervisor,fp.sensor.supervisor,model_fp_sensor,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_manager,fp.sensor.manager,model_fp_sensor,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_measurement_operator,fp.sensor.measurement.operator,model_fp_sensor_measurement,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_sensor_measurement_supervisor,fp.sensor.measurement.supervisor,model_fp_sensor_measurement,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_measurement_manager,fp.sensor.measurement.manager,model_fp_sensor_measurement,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_dashboard_operator,fp.sensor.dashboard.operator,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_dashboard_supervisor,fp.sensor.dashboard.supervisor,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_dashboard_manager,fp.sensor.dashboard.manager,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_alert_rule_operator,fp.sensor.alert.rule.operator,model_fp_sensor_alert_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_alert_rule_supervisor,fp.sensor.alert.rule.supervisor,model_fp_sensor_alert_rule,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_alert_rule_manager,fp.sensor.alert.rule.manager,model_fp_sensor_alert_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_measure_wizard_operator,fp.sensor.measure.wizard.operator,model_fp_sensor_measure_wizard,fusion_plating.group_fusion_plating_operator,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_sensor_type_operator fp.sensor.type.operator model_fp_sensor_type fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_sensor_type_supervisor fp.sensor.type.supervisor model_fp_sensor_type fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_sensor_type_manager fp.sensor.type.manager model_fp_sensor_type fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_sensor_operator fp.sensor.operator model_fp_sensor fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_sensor_supervisor fp.sensor.supervisor model_fp_sensor fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_sensor_manager fp.sensor.manager model_fp_sensor fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_sensor_measurement_operator fp.sensor.measurement.operator model_fp_sensor_measurement fusion_plating.group_fusion_plating_operator 1 1 1 0
9 access_fp_sensor_measurement_supervisor fp.sensor.measurement.supervisor model_fp_sensor_measurement fusion_plating.group_fusion_plating_supervisor 1 1 1 0
10 access_fp_sensor_measurement_manager fp.sensor.measurement.manager model_fp_sensor_measurement fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_sensor_dashboard_operator fp.sensor.dashboard.operator model_fp_sensor_dashboard fusion_plating.group_fusion_plating_operator 1 0 0 0
12 access_fp_sensor_dashboard_supervisor fp.sensor.dashboard.supervisor model_fp_sensor_dashboard fusion_plating.group_fusion_plating_supervisor 1 1 1 0
13 access_fp_sensor_dashboard_manager fp.sensor.dashboard.manager model_fp_sensor_dashboard fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_sensor_alert_rule_operator fp.sensor.alert.rule.operator model_fp_sensor_alert_rule fusion_plating.group_fusion_plating_operator 1 0 0 0
15 access_fp_sensor_alert_rule_supervisor fp.sensor.alert.rule.supervisor model_fp_sensor_alert_rule fusion_plating.group_fusion_plating_supervisor 1 1 1 0
16 access_fp_sensor_alert_rule_manager fp.sensor.alert.rule.manager model_fp_sensor_alert_rule fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_sensor_measure_wizard_operator fp.sensor.measure.wizard.operator model_fp_sensor_measure_wizard fusion_plating.group_fusion_plating_operator 1 1 1 1

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Dashboard Form ===== -->
<record id="view_fp_sensor_dashboard_form" model="ir.ui.view">
<field name="name">fp.sensor.dashboard.form</field>
<field name="model">fp.sensor.dashboard</field>
<field name="arch" type="xml">
<form string="Sensor Dashboard">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. % Nickel Activity"/>
</h1>
</div>
<group>
<group>
<field name="member_count" readonly="1"/>
<field name="alert_count" readonly="1"/>
</group>
<group>
<field name="active" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Sensors" name="sensors">
<field name="sensor_ids" widget="many2many_tags"/>
<field name="sensor_ids" mode="list">
<list>
<field name="name"/>
<field name="unit"/>
<field name="last_value" string="Last Value"/>
<field name="last_measured"/>
<field name="work_center_id" string="Station"/>
</list>
</field>
</page>
<page string="Alert Rules" name="alerts">
<field name="alert_rule_ids">
<list editable="bottom">
<field name="sensor_id"/>
<field name="threshold_low"/>
<field name="threshold_high"/>
<field name="active"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Dashboard List ===== -->
<record id="view_fp_sensor_dashboard_list" model="ir.ui.view">
<field name="name">fp.sensor.dashboard.list</field>
<field name="model">fp.sensor.dashboard</field>
<field name="arch" type="xml">
<list string="Sensor Dashboards" default_order="name">
<field name="name"/>
<field name="alert_count" string="Alerts"
decoration-danger="alert_count > 0"/>
<field name="member_count" string="Members"/>
</list>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor_dashboard" model="ir.actions.act_window">
<field name="name">Sensor Dashboards</field>
<field name="res_model">fp.sensor.dashboard</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a sensor dashboard
</p>
<p>Group sensors into dashboards for monitoring and alerting.</p>
</field>
</record>
</odoo>

View File

@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Quick Measure Wizard Form ===== -->
<record id="view_fp_sensor_measure_wizard_form" model="ir.ui.view">
<field name="name">fp.sensor.measure.wizard.form</field>
<field name="model">fp.sensor.measure.wizard</field>
<field name="arch" type="xml">
<form string="Record Measurement">
<group>
<field name="sensor_id"/>
<field name="measurement_type" invisible="1"/>
<field name="value"
invisible="measurement_type != 'number'"/>
<field name="value_text"
invisible="measurement_type != 'text'"/>
<field name="value_bool"
invisible="measurement_type != 'boolean'"/>
<field name="unit" invisible="not unit"/>
<field name="effective_at"/>
<field name="comment" placeholder="Write a comment..."/>
</group>
<footer>
<button name="action_confirm" string="Save" type="object"
class="btn-primary"/>
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ===== Wizard Action (called from sensor form) ===== -->
<record id="action_fp_sensor_measure_wizard" model="ir.actions.act_window">
<field name="name">Record Measurement</field>
<field name="res_model">fp.sensor.measure.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -1,106 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Measurement Form ===== -->
<record id="view_fp_sensor_measurement_form" model="ir.ui.view">
<field name="name">fp.sensor.measurement.form</field>
<field name="model">fp.sensor.measurement</field>
<field name="arch" type="xml">
<form string="Sensor Measurement">
<sheet>
<group>
<group>
<field name="name" readonly="1"/>
<field name="sensor_id"/>
<field name="measurement_type" invisible="1"/>
<field name="value" invisible="measurement_type != 'number'"/>
<field name="value_text" invisible="measurement_type != 'text'"/>
<field name="value_bool" invisible="measurement_type != 'boolean'"/>
<field name="unit"/>
</group>
<group>
<field name="effective_at"/>
<field name="source"/>
<field name="creator_id" widget="many2one_avatar_user"/>
</group>
</group>
<group>
<field name="comment" placeholder="Write a comment..."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Measurement List ===== -->
<record id="view_fp_sensor_measurement_list" model="ir.ui.view">
<field name="name">fp.sensor.measurement.list</field>
<field name="model">fp.sensor.measurement</field>
<field name="arch" type="xml">
<list string="Sensor Measurements" default_order="effective_at desc"
decoration-muted="source == 'api'">
<field name="value" string="Measurement"/>
<field name="value_text" optional="hide"/>
<field name="comment"/>
<field name="sensor_id"/>
<field name="measurement_type" column_invisible="1"/>
<field name="creator_id" widget="many2one_avatar_user" string="Creator"/>
<field name="effective_at" string="Created At"/>
<field name="source" widget="badge" optional="show"
decoration-info="source == 'manual'"
decoration-success="source == 'api'"
decoration-warning="source == 'iot'"/>
</list>
</field>
</record>
<!-- ===== Measurement Search ===== -->
<record id="view_fp_sensor_measurement_search" model="ir.ui.view">
<field name="name">fp.sensor.measurement.search</field>
<field name="model">fp.sensor.measurement</field>
<field name="arch" type="xml">
<search>
<field name="sensor_id"/>
<field name="creator_id"/>
<separator/>
<filter string="Manual" name="filter_manual"
domain="[('source', '=', 'manual')]"/>
<filter string="API" name="filter_api"
domain="[('source', '=', 'api')]"/>
<filter string="IoT" name="filter_iot"
domain="[('source', '=', 'iot')]"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('effective_at', '>=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="This Week" name="filter_week"
domain="[('effective_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<group>
<filter string="Sensor" name="group_sensor"
context="{'group_by': 'sensor_id'}"/>
<filter string="Creator" name="group_creator"
context="{'group_by': 'creator_id'}"/>
<filter string="Source" name="group_source"
context="{'group_by': 'source'}"/>
<filter string="Date" name="group_date"
context="{'group_by': 'effective_at:day'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor_measurement" model="ir.actions.act_window">
<field name="name">Sensor Measurements</field>
<field name="res_model">fp.sensor.measurement</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_sensor_measurement_search"/>
<field name="context">{'search_default_filter_today': 0}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No measurements recorded yet
</p>
<p>Measurements are recorded manually via the sensor form or automatically via the IoT API.</p>
</field>
</record>
</odoo>

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Sensors parent menu under Operations ===== -->
<menuitem id="menu_fp_sensors"
name="Sensors"
parent="fusion_plating.menu_fp_operations"
sequence="20"/>
<menuitem id="menu_fp_sensor_dashboards"
name="Dashboards"
parent="menu_fp_sensors"
action="action_fp_sensor_dashboard"
sequence="10"/>
<menuitem id="menu_fp_sensors_all"
name="All Sensors"
parent="menu_fp_sensors"
action="action_fp_sensor"
sequence="20"/>
<menuitem id="menu_fp_sensor_measurements"
name="Measurements"
parent="menu_fp_sensors"
action="action_fp_sensor_measurement"
sequence="30"/>
<menuitem id="menu_fp_sensor_types"
name="Sensor Types"
parent="menu_fp_sensors"
action="action_fp_sensor_type"
sequence="40"/>
</odoo>

View File

@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Sensor Type Form ===== -->
<record id="view_fp_sensor_type_form" model="ir.ui.view">
<field name="name">fp.sensor.type.form</field>
<field name="model">fp.sensor.type</field>
<field name="arch" type="xml">
<form string="Sensor Type">
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-microchip"
type="object" name="action_view_sensors"
invisible="sensor_count == 0">
<field name="sensor_count" widget="statinfo" string="Sensors"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="measurement_type"/>
</group>
<group>
<field name="active" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Sensor Type List ===== -->
<record id="view_fp_sensor_type_list" model="ir.ui.view">
<field name="name">fp.sensor.type.list</field>
<field name="model">fp.sensor.type</field>
<field name="arch" type="xml">
<list string="Sensor Types" default_order="name">
<field name="name"/>
<field name="measurement_type"/>
<field name="sensor_count" string="Sensors"/>
</list>
</field>
</record>
<!-- ===== Sensor Type Search ===== -->
<record id="view_fp_sensor_type_search" model="ir.ui.view">
<field name="name">fp.sensor.type.search</field>
<field name="model">fp.sensor.type</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Number" name="filter_number" domain="[('measurement_type', '=', 'number')]"/>
<filter string="Text" name="filter_text" domain="[('measurement_type', '=', 'text')]"/>
<filter string="Boolean" name="filter_boolean" domain="[('measurement_type', '=', 'boolean')]"/>
<group>
<filter string="Type" name="group_type" context="{'group_by': 'measurement_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor_type" model="ir.actions.act_window">
<field name="name">Sensor Types</field>
<field name="res_model">fp.sensor.type</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_sensor_type_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a sensor type
</p>
<p>Sensor types define what a sensor measures (Number, Text, or Boolean).</p>
</field>
</record>
</odoo>

View File

@@ -1,148 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Sensor Form ===== -->
<record id="view_fp_sensor_form" model="ir.ui.view">
<field name="name">fp.sensor.form</field>
<field name="model">fp.sensor</field>
<field name="arch" type="xml">
<form string="Sensor">
<header>
<button name="action_quick_measure"
string="+ Measure"
type="object"
class="btn-primary"
icon="fa-plus"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-bar-chart"
type="object" name="action_view_measurements">
<field name="measurement_count" widget="statinfo"
string="Measurements"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. Waste Water Treatment pH"/>
</h1>
</div>
<group>
<group string="Sensor Configuration">
<field name="sensor_type_id"/>
<field name="measurement_type" invisible="1"/>
<field name="unit" placeholder="e.g. ph, %, g/L, PPM"/>
<field name="uuid" placeholder="Hardware UUID for IoT"/>
<field name="use_location"/>
</group>
<group string="Location">
<field name="work_center_id" string="Station"/>
<field name="tank_id"/>
<field name="facility_id"/>
<field name="location_name"
invisible="not use_location"
placeholder="e.g. WaterTreatmentArea"/>
</group>
</group>
<group string="Last Reading">
<group>
<field name="last_value" readonly="1"
invisible="measurement_type != 'number'"/>
<field name="last_value_text" readonly="1"
invisible="measurement_type != 'text'"/>
</group>
<group>
<field name="last_measured" readonly="1"/>
</group>
</group>
<notebook>
<page string="Recent Measurements" name="measurements">
<field name="measurement_ids" mode="list" limit="5">
<list editable="bottom" default_order="effective_at desc"
decoration-muted="source == 'api'">
<field name="effective_at" string="Date"/>
<field name="value"
invisible="parent.measurement_type != 'number'"/>
<field name="value_text"
invisible="parent.measurement_type != 'text'"/>
<field name="value_bool"
invisible="parent.measurement_type != 'boolean'"/>
<field name="comment"/>
<field name="creator_id" widget="many2one_avatar_user"/>
<field name="source" widget="badge"
decoration-info="source == 'manual'"
decoration-success="source == 'api'"
decoration-warning="source == 'iot'"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Sensor List ===== -->
<record id="view_fp_sensor_list" model="ir.ui.view">
<field name="name">fp.sensor.list</field>
<field name="model">fp.sensor</field>
<field name="arch" type="xml">
<list string="Sensors" default_order="name">
<field name="name"/>
<field name="sensor_type_id"/>
<field name="last_value" string="Last Measurement"/>
<field name="unit"/>
<field name="measurement_type" column_invisible="1"/>
<field name="last_measured" string="Last Measured"/>
<field name="work_center_id" string="Station" optional="show"/>
<field name="location_name" string="Location" optional="show"/>
<field name="uuid" optional="hide"/>
</list>
</field>
</record>
<!-- ===== Sensor Search ===== -->
<record id="view_fp_sensor_search" model="ir.ui.view">
<field name="name">fp.sensor.search</field>
<field name="model">fp.sensor</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="uuid"/>
<field name="sensor_type_id"/>
<field name="work_center_id" string="Station"/>
<field name="tank_id"/>
<field name="facility_id"/>
<separator/>
<filter string="Number Sensors" name="filter_number"
domain="[('measurement_type', '=', 'number')]"/>
<filter string="Has UUID" name="filter_has_uuid"
domain="[('uuid', '!=', False)]"/>
<group>
<filter string="Station" name="group_station"
context="{'group_by': 'work_center_id'}"/>
<filter string="Type" name="group_type"
context="{'group_by': 'sensor_type_id'}"/>
<filter string="Facility" name="group_facility"
context="{'group_by': 'facility_id'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor" model="ir.actions.act_window">
<field name="name">Sensors</field>
<field name="res_model">fp.sensor</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_sensor_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first sensor
</p>
<p>Sensors track chemistry readings at tanks, work centres, and other locations.</p>
</field>
</record>
</odoo>

View File

@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_sensor_measure_wizard

View File

@@ -1,57 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensorMeasureWizard(models.TransientModel):
"""Quick measurement entry wizard.
Opened from the "+ Measure" button on the sensor form.
Pre-fills sensor and unit, operator enters value + date + comment.
"""
_name = 'fp.sensor.measure.wizard'
_description = 'Record Sensor Measurement'
sensor_id = fields.Many2one(
'fp.sensor',
string='Sensor',
required=True,
readonly=True,
)
measurement_type = fields.Selection(
related='sensor_id.measurement_type',
readonly=True,
)
unit = fields.Char(
related='sensor_id.unit',
readonly=True,
)
value = fields.Float(string='Value')
value_text = fields.Char(string='Text Value')
value_bool = fields.Boolean(string='Boolean Value')
effective_at = fields.Datetime(
string='Date',
default=fields.Datetime.now,
required=True,
)
comment = fields.Text(string='Comment')
def action_confirm(self):
self.ensure_one()
vals = {
'sensor_id': self.sensor_id.id,
'effective_at': self.effective_at,
'comment': self.comment,
'source': 'manual',
}
mtype = self.sensor_id.measurement_type
if mtype == 'number':
vals['value'] = self.value
elif mtype == 'text':
vals['value_text'] = self.value_text
elif mtype == 'boolean':
vals['value_bool'] = self.value_bool
self.env['fp.sensor.measurement'].create(vals)
return {'type': 'ir.actions.act_window_close'}