changes
This commit is contained in:
9
fusion-plating/fusion_plating_sensors/models/__init__.py
Normal file
9
fusion-plating/fusion_plating_sensors/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- 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
|
||||
167
fusion-plating/fusion_plating_sensors/models/fp_sensor.py
Normal file
167
fusion-plating/fusion_plating_sensors/models/fp_sensor.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- 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,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- 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
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- 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},
|
||||
}
|
||||
Reference in New Issue
Block a user