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:
@@ -5,3 +5,4 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
from .hooks import post_init_hook
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — IoT Integration',
|
'name': 'Fusion Plating — IoT Integration',
|
||||||
'version': '19.0.0.3.0',
|
'version': '19.0.1.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Wire physical tank sensors to Fusion Plating — live '
|
'summary': 'Wire physical tank sensors to Fusion Plating — live '
|
||||||
'temperature / chemistry readings with auto quality holds '
|
'temperature / chemistry readings with auto quality holds '
|
||||||
@@ -45,11 +45,15 @@ Part of the Fusion Plating product family by Nexa Systems Inc.
|
|||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/ir_config_parameter_data.xml',
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'data/fp_sensor_type_data.xml',
|
||||||
|
'views/fp_sensor_type_views.xml',
|
||||||
|
'views/fp_sensor_dashboard_views.xml',
|
||||||
'views/fp_tank_sensor_views.xml',
|
'views/fp_tank_sensor_views.xml',
|
||||||
'views/fp_tank_reading_views.xml',
|
'views/fp_tank_reading_views.xml',
|
||||||
'views/fusion_plating_tank_views.xml',
|
'views/fusion_plating_tank_views.xml',
|
||||||
'views/fp_iot_menu.xml',
|
'views/fp_iot_menu.xml',
|
||||||
],
|
],
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
82
fusion_iot/fusion_plating_iot/data/fp_sensor_type_data.xml
Normal file
82
fusion_iot/fusion_plating_iot/data/fp_sensor_type_data.xml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
|
||||||
|
Default sensor types — the common ones every plating shop needs.
|
||||||
|
noupdate="1" so admins can tweak without losing edits on module upgrade.
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_temperature" model="fp.sensor.type">
|
||||||
|
<field name="name">Temperature</field>
|
||||||
|
<field name="code">temperature</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">°C</field>
|
||||||
|
<field name="icon">fa-thermometer-half</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_ph" model="fp.sensor.type">
|
||||||
|
<field name="name">pH</field>
|
||||||
|
<field name="code">ph</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">pH</field>
|
||||||
|
<field name="icon">fa-flask</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_conductivity" model="fp.sensor.type">
|
||||||
|
<field name="name">Conductivity</field>
|
||||||
|
<field name="code">conductivity</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">µS/cm</field>
|
||||||
|
<field name="icon">fa-bolt</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_level" model="fp.sensor.type">
|
||||||
|
<field name="name">Tank Level</field>
|
||||||
|
<field name="code">level</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">cm</field>
|
||||||
|
<field name="icon">fa-tint</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_pressure" model="fp.sensor.type">
|
||||||
|
<field name="name">Pressure</field>
|
||||||
|
<field name="code">pressure</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">kPa</field>
|
||||||
|
<field name="icon">fa-tachometer</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_flow" model="fp.sensor.type">
|
||||||
|
<field name="name">Flow Rate</field>
|
||||||
|
<field name="code">flow</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">L/min</field>
|
||||||
|
<field name="icon">fa-cogs</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_concentration" model="fp.sensor.type">
|
||||||
|
<field name="name">Concentration</field>
|
||||||
|
<field name="code">concentration</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="measurement_type">number</field>
|
||||||
|
<field name="default_uom">g/L</field>
|
||||||
|
<field name="icon">fa-vial</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_sensor_type_switch" model="fp.sensor.type">
|
||||||
|
<field name="name">Limit / Float Switch</field>
|
||||||
|
<field name="code">switch</field>
|
||||||
|
<field name="sequence">100</field>
|
||||||
|
<field name="measurement_type">boolean</field>
|
||||||
|
<field name="icon">fa-toggle-on</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
76
fusion_iot/fusion_plating_iot/hooks.py
Normal file
76
fusion_iot/fusion_plating_iot/hooks.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Post-install hook — backfill new fields on existing live sensors.
|
||||||
|
|
||||||
|
Runs once on every install/upgrade. Idempotent: checks before writing
|
||||||
|
so re-runs don't overwrite user-edited values.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. Populates `uuid` on any fp.tank.sensor record that doesn't have one
|
||||||
|
(for sensors created BEFORE the uuid field existed — the create
|
||||||
|
override only covers new records).
|
||||||
|
2. Sets a default `sensor_type_id` on sensors that don't have one yet,
|
||||||
|
inferring from `device_kind` (DS18B20 / PT100 / PT1000 → Temperature,
|
||||||
|
pH → pH probe, etc.).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def post_init_hook(env):
|
||||||
|
_backfill_uuids(env)
|
||||||
|
_backfill_sensor_types(env)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_uuids(env):
|
||||||
|
Sensor = env['fp.tank.sensor']
|
||||||
|
missing = Sensor.search([('uuid', '=', False)])
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
for s in missing:
|
||||||
|
s.sudo().write({'uuid': _uuid.uuid4().hex})
|
||||||
|
_logger.info('fp.tank.sensor: populated UUID on %d existing records',
|
||||||
|
len(missing))
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_sensor_types(env):
|
||||||
|
Sensor = env['fp.tank.sensor']
|
||||||
|
Type = env['fp.sensor.type']
|
||||||
|
|
||||||
|
# Map device_kind → sensor-type code. Falls back to 'temperature' for
|
||||||
|
# the rare case someone set device_kind='other' on a probe that IS
|
||||||
|
# temperature (common on the pilot).
|
||||||
|
kind_to_code = {
|
||||||
|
'ds18b20': 'temperature',
|
||||||
|
'pt100': 'temperature',
|
||||||
|
'pt1000': 'temperature',
|
||||||
|
'ph': 'ph',
|
||||||
|
'conductivity': 'conductivity',
|
||||||
|
'level': 'level',
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = Sensor.search([('sensor_type_id', '=', False)])
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve the types once up front
|
||||||
|
type_cache = {}
|
||||||
|
for code in set(kind_to_code.values()):
|
||||||
|
t = Type.search([('code', '=', code)], limit=1)
|
||||||
|
if t:
|
||||||
|
type_cache[code] = t.id
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for s in missing:
|
||||||
|
code = kind_to_code.get(s.device_kind)
|
||||||
|
# Unmapped (device_kind='other') → try temperature as the most
|
||||||
|
# common fallback. Admin can correct in the UI.
|
||||||
|
type_id = type_cache.get(code) or type_cache.get('temperature')
|
||||||
|
if type_id:
|
||||||
|
s.sudo().write({'sensor_type_id': type_id})
|
||||||
|
updated += 1
|
||||||
|
_logger.info('fp.tank.sensor: set default sensor_type_id on %d records',
|
||||||
|
updated)
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import fp_sensor_type
|
||||||
|
from . import fp_sensor_dashboard
|
||||||
from . import fp_tank_sensor
|
from . import fp_tank_sensor
|
||||||
from . import fp_tank_reading
|
from . import fp_tank_reading
|
||||||
from . import fusion_plating_tank
|
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,
|
string='Sensor Name', required=True,
|
||||||
help='Human label (e.g. "Tank 3 — ENP temp").',
|
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)
|
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(
|
tank_id = fields.Many2one(
|
||||||
'fusion.plating.tank', string='Tank', ondelete='cascade',
|
'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 '
|
help='Optional — if the sensor is bound to a specific bath '
|
||||||
'chemistry rather than a physical tank.',
|
'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(
|
parameter_id = fields.Many2one(
|
||||||
'fusion.plating.bath.parameter', string='Parameter Measured',
|
'fusion.plating.bath.parameter', string='Parameter Measured',
|
||||||
required=True,
|
required=True,
|
||||||
@@ -74,6 +110,27 @@ class FpTankSensor(models.Model):
|
|||||||
'etc.). Drives unit labelling + out-of-spec alerting against '
|
'etc.). Drives unit labelling + out-of-spec alerting against '
|
||||||
'the parameter\'s target_min / target_max.',
|
'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:
|
# Target / alerting behaviour — three concepts:
|
||||||
@@ -185,8 +242,19 @@ class FpTankSensor(models.Model):
|
|||||||
('fp_tank_sensor_serial_uniq',
|
('fp_tank_sensor_serial_uniq',
|
||||||
'unique(device_serial)',
|
'unique(device_serial)',
|
||||||
'Each hardware serial can only be mapped to one sensor.'),
|
'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')
|
@api.depends('reading_ids')
|
||||||
def _compute_reading_count(self):
|
def _compute_reading_count(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ fp_tank_sensor_manager,fp.tank.sensor manager,model_fp_tank_sensor,fusion_platin
|
|||||||
fp_tank_reading_operator,fp.tank.reading operator,model_fp_tank_reading,fusion_plating.group_fusion_plating_operator,1,0,1,0
|
fp_tank_reading_operator,fp.tank.reading operator,model_fp_tank_reading,fusion_plating.group_fusion_plating_operator,1,0,1,0
|
||||||
fp_tank_reading_supervisor,fp.tank.reading supervisor,model_fp_tank_reading,fusion_plating.group_fusion_plating_supervisor,1,0,1,0
|
fp_tank_reading_supervisor,fp.tank.reading supervisor,model_fp_tank_reading,fusion_plating.group_fusion_plating_supervisor,1,0,1,0
|
||||||
fp_tank_reading_manager,fp.tank.reading manager,model_fp_tank_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
fp_tank_reading_manager,fp.tank.reading manager,model_fp_tank_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
fp_sensor_type_operator,fp.sensor.type operator,model_fp_sensor_type,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
|
fp_sensor_type_supervisor,fp.sensor.type supervisor,model_fp_sensor_type,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||||
|
fp_sensor_type_manager,fp.sensor.type manager,model_fp_sensor_type,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
fp_sensor_dashboard_operator,fp.sensor.dashboard operator,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
|
fp_sensor_dashboard_supervisor,fp.sensor.dashboard supervisor,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
fp_sensor_dashboard_manager,fp.sensor.dashboard manager,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -3,27 +3,39 @@
|
|||||||
Copyright 2026 Nexa Systems Inc.
|
Copyright 2026 Nexa Systems Inc.
|
||||||
License OPL-1
|
License OPL-1
|
||||||
|
|
||||||
Surface IoT sensors + readings under the existing Plating >
|
Single "Sensors" section under Plating > Operations. Consolidates
|
||||||
Operations menu. Not a top-level app — sensors are an extension
|
all sensor management into one place (formerly split across an
|
||||||
of bath/tank management, not a separate concern.
|
obsolete fusion_plating_sensors module).
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<menuitem id="menu_fp_iot_root"
|
<menuitem id="menu_fp_iot_root"
|
||||||
name="Sensors & Readings"
|
name="Sensors"
|
||||||
parent="fusion_plating.menu_fp_operations"
|
parent="fusion_plating.menu_fp_operations"
|
||||||
sequence="55"/>
|
sequence="55"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_tank_sensor"
|
<menuitem id="menu_fp_sensor_dashboard"
|
||||||
name="Tank Sensors"
|
name="Dashboards"
|
||||||
parent="menu_fp_iot_root"
|
parent="menu_fp_iot_root"
|
||||||
action="action_fp_tank_sensor"
|
action="action_fp_sensor_dashboard"
|
||||||
sequence="10"/>
|
sequence="10"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_tank_reading"
|
<menuitem id="menu_fp_tank_sensor"
|
||||||
name="Sensor Readings"
|
name="Sensors"
|
||||||
parent="menu_fp_iot_root"
|
parent="menu_fp_iot_root"
|
||||||
action="action_fp_tank_reading"
|
action="action_fp_tank_sensor"
|
||||||
sequence="20"/>
|
sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_tank_reading"
|
||||||
|
name="Readings"
|
||||||
|
parent="menu_fp_iot_root"
|
||||||
|
action="action_fp_tank_reading"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_sensor_type"
|
||||||
|
name="Sensor Types"
|
||||||
|
parent="menu_fp_iot_root"
|
||||||
|
action="action_fp_sensor_type"
|
||||||
|
sequence="40"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="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"
|
||||||
|
decoration-danger="out_of_spec_count > 0">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="description" optional="show"/>
|
||||||
|
<field name="sensor_count"/>
|
||||||
|
<field name="out_of_spec_count"/>
|
||||||
|
<field name="color" widget="color_picker" optional="show"/>
|
||||||
|
<field name="active" widget="boolean_toggle" optional="show"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="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_button_box" name="button_box">
|
||||||
|
<button name="action_view_sensors" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-microchip">
|
||||||
|
<field name="sensor_count" widget="statinfo"
|
||||||
|
string="Sensors"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_recent_readings" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-line-chart">
|
||||||
|
<field name="out_of_spec_count" widget="statinfo"
|
||||||
|
string="Out of Spec"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="e.g. ENP Line 1"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="description" placeholder="What this dashboard shows…"/>
|
||||||
|
<field name="color" widget="color_picker"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Sensors">
|
||||||
|
<field name="sensor_ids">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="sensor_type_id"/>
|
||||||
|
<field name="effective_location"/>
|
||||||
|
<field name="last_reading_display"/>
|
||||||
|
<field name="last_reading_display_unit"/>
|
||||||
|
<field name="last_reading_in_spec" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_sensor_dashboard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Dashboards</field>
|
||||||
|
<field name="res_model">fp.sensor.dashboard</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
60
fusion_iot/fusion_plating_iot/views/fp_sensor_type_views.xml
Normal file
60
fusion_iot/fusion_plating_iot/views/fp_sensor_type_views.xml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="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" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="measurement_type"/>
|
||||||
|
<field name="default_uom"/>
|
||||||
|
<field name="icon" optional="hide"/>
|
||||||
|
<field name="sensor_count"/>
|
||||||
|
<field name="active" widget="boolean_toggle" optional="show"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="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 name="action_view_sensors" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-microchip">
|
||||||
|
<field name="sensor_count" widget="statinfo"
|
||||||
|
string="Sensors"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="e.g. Temperature"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="measurement_type"/>
|
||||||
|
<field name="default_uom"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="icon"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -10,14 +10,19 @@
|
|||||||
<field name="name">fp.tank.sensor.list</field>
|
<field name="name">fp.tank.sensor.list</field>
|
||||||
<field name="model">fp.tank.sensor</field>
|
<field name="model">fp.tank.sensor</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Tank Sensors" decoration-danger="not last_reading_in_spec and last_reading_at"
|
<list string="Sensors" decoration-danger="not last_reading_in_spec and last_reading_at"
|
||||||
decoration-muted="not active">
|
decoration-muted="not active">
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="device_kind"/>
|
<field name="sensor_type_id" optional="show"/>
|
||||||
<field name="tank_id"/>
|
<field name="device_kind" optional="show"/>
|
||||||
<field name="bath_id" optional="show"/>
|
<field name="effective_location" string="Location" optional="show"/>
|
||||||
|
<field name="tank_id" optional="hide"/>
|
||||||
|
<field name="work_center_id" optional="hide"/>
|
||||||
|
<field name="facility_id" optional="hide"/>
|
||||||
|
<field name="bath_id" optional="hide"/>
|
||||||
<field name="parameter_id"/>
|
<field name="parameter_id"/>
|
||||||
<field name="device_serial" optional="show"/>
|
<field name="device_serial" optional="show"/>
|
||||||
|
<field name="uuid" optional="hide"/>
|
||||||
<field name="iot_device_id" optional="hide"/>
|
<field name="iot_device_id" optional="hide"/>
|
||||||
<field name="last_reading_display"/>
|
<field name="last_reading_display"/>
|
||||||
<field name="last_reading_display_unit"/>
|
<field name="last_reading_display_unit"/>
|
||||||
@@ -55,18 +60,30 @@
|
|||||||
<h1><field name="name" placeholder="e.g. Tank 3 — ENP temp"/></h1>
|
<h1><field name="name" placeholder="e.g. Tank 3 — ENP temp"/></h1>
|
||||||
</div>
|
</div>
|
||||||
<group>
|
<group>
|
||||||
|
<group string="Identity">
|
||||||
|
<field name="sensor_type_id" options="{'no_create': True}"/>
|
||||||
|
<field name="uuid" readonly="1"/>
|
||||||
|
<field name="parameter_id" options="{'no_create': True}"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
<group string="Hardware">
|
<group string="Hardware">
|
||||||
<field name="device_kind"/>
|
<field name="device_kind"/>
|
||||||
<field name="device_serial" placeholder="28-abc123def456"/>
|
<field name="device_serial" placeholder="28-abc123def456"/>
|
||||||
<field name="iot_device_id"
|
<field name="iot_device_id"
|
||||||
options="{'no_create': True}"
|
options="{'no_create': True}"
|
||||||
help="Optional — the iot.device auto-registered by the Pi proxy."/>
|
help="Optional — the iot.device auto-registered by the Pi proxy."/>
|
||||||
<field name="active"/>
|
|
||||||
</group>
|
</group>
|
||||||
<group string="Location">
|
</group>
|
||||||
|
<group string="Location — fill in ONE (first one set wins for display)">
|
||||||
|
<group>
|
||||||
<field name="tank_id" options="{'no_create': True}"/>
|
<field name="tank_id" options="{'no_create': True}"/>
|
||||||
<field name="bath_id" options="{'no_create': True}"/>
|
<field name="bath_id" options="{'no_create': True}"/>
|
||||||
<field name="parameter_id" options="{'no_create': True}"/>
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="work_center_id" options="{'no_create': True}"/>
|
||||||
|
<field name="facility_id" options="{'no_create': True}"/>
|
||||||
|
<field name="location_name"/>
|
||||||
|
<field name="effective_location" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group string="Setpoint & Alerting">
|
<group string="Setpoint & Alerting">
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
|
|
||||||
from . import sensor_controller
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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},
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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,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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
@@ -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'}
|
|
||||||
Reference in New Issue
Block a user