feat(iot): repackaged Odoo iot modules + Fusion Plating sensor wrapper
Phase A of the IoT initiative — gets the server-side infrastructure
in place before the Raspberry Pi hardware arrives, so the iot admin
UI + /fp/iot/ingest endpoint are ready to accept the first real
temperature reading as soon as the Pi is wired up.
New top-level folder: fusion_iot/
1. **iot_base/** — Odoo S.A. iot_base module, copied from
RePackaged-Odoo verbatim. LGPL-3 upstream, no changes needed.
2. **iot/** — Odoo S.A. iot module, repackaged:
- `models/update.py` neutralised (removed the publisher_warranty
IoT-Box-counting report that phones home to odoo.com for
enterprise licence enforcement)
- `iot_handlers/lib/load_worldline_library.sh` deleted (proprietary
Worldline payment lib fetch from download.odoo.com, not needed)
- `wizard/add_iot_box.py._connect_iot_box_with_pairing_code` —
upstream called odoo.com's iot-proxy to resolve pairing codes;
replaced with a no-op. Pi-side iot_drivers proxy registers
directly with this Odoo server instead.
- Manifest rebranded with an explicit changelog preamble.
3. **fusion_plating_iot/** — new plating-specific wrapper:
- `fp.tank.sensor` — maps an iot.device (or a direct-HTTP-ingest
sensor) to a fusion.plating.tank + fusion.plating.bath.parameter.
Supports DS18B20, PT100/1000, pH, conductivity, level. Per-sensor
alert_min/max overrides.
- `fp.tank.reading` — append-only time-series. On create, evaluates
against sensor's alert range. On in-spec → out-of-spec TRANSITION,
auto-raises a fusion.plating.quality.hold (once per excursion,
no spam during sustained out-of-spec).
- `POST /fp/iot/ingest` — shared-secret HTTP endpoint for sensors
bypassing the Pi proxy. Token via X-FP-IOT-Token header OR body.
Accepts single-reading or batch payloads.
- Menu under Plating → Operations → Sensors & Readings.
- Tank form inherits get a Sensors tab inline.
Deployed to entech. Verified end-to-end:
- Install: iot_base + iot + fusion_plating_iot all 'installed'
- Smoke test: in-spec → out-of-spec → hold raised (HOLD-0010);
continued excursion → NO duplicate hold; back-in-spec → NEW
excursion → NEW hold (HOLD-0011) ✓
- HTTP endpoint: correct token → 200 accepted; wrong token → 401;
unknown device_serial → 404; batch payload → 200 accepted=N ✓
Phase B (when Raspberry Pi hardware arrives): DS18B20 iot_handler
driver for the Pi-side iot_drivers proxy + systemd service on
vanilla Raspberry Pi OS + first live reading from physical probe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
fusion_iot/CLAUDE.md
Normal file
130
fusion_iot/CLAUDE.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Fusion IoT — Claude Code Instructions
|
||||
|
||||
## Purpose
|
||||
|
||||
Fusion IoT lets Fusion Apps products ingest live sensor readings from
|
||||
hardware mounted on a shop floor — initially tank temperature probes
|
||||
for Fusion Plating, with room to grow into label printers, scales,
|
||||
and any other device Odoo's IoT framework supports.
|
||||
|
||||
## Folder contents
|
||||
|
||||
```
|
||||
fusion_iot/
|
||||
├── iot_base/ # Repackaged from Odoo S.A. — shared JS utils
|
||||
├── iot/ # Repackaged from Odoo S.A. — IoT Box mgmt models + UI
|
||||
└── fusion_plating_iot/ # Our wrapper — sensor→tank mapping + out-of-spec holds
|
||||
```
|
||||
|
||||
## Repackaging notes — `iot_base` + `iot`
|
||||
|
||||
Both copied as-is from `/Users/gurpreet/Github/RePackaged-Odoo/_dependencies/`
|
||||
(tag Odoo 19). Both are already LGPL-3 upstream — no license flip needed.
|
||||
|
||||
**Gutted phone-home**:
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `iot/models/update.py` | `Publisher_WarrantyContract._get_message` override REMOVED (no more IoT-Box counting-back to Odoo S.A. for enterprise licensing) |
|
||||
| `iot/iot_handlers/lib/load_worldline_library.sh` | DELETED (proprietary Worldline payment lib fetch from download.odoo.com — we don't use Worldline) |
|
||||
|
||||
**Left intact** (NOT phone-home, don't remove):
|
||||
|
||||
- `ir_config_parameter.py` — broadcasts `web.base.url` changes to paired IoT boxes via the internal IoT channel (not the internet)
|
||||
- `iot_box.py.version_commit_url` — cosmetic link to odoo/odoo on GitHub
|
||||
- `controllers/main.py` — serves the iot handlers zip to the Pi (this is the point of the module)
|
||||
|
||||
## `fusion_plating_iot` — the wrapper
|
||||
|
||||
### Models
|
||||
|
||||
**`fp.tank.sensor`** — maps a physical sensor to a tank + parameter
|
||||
- `device_serial` — hardware unique ID (e.g. DS18B20 1-Wire address)
|
||||
- `iot_device_id` — optional link to `iot.device` if the sensor comes in via Pi proxy
|
||||
- `tank_id` / `bath_id` — where the sensor lives
|
||||
- `parameter_id` — what bath parameter it reports (temperature, pH, etc.)
|
||||
- `alert_min_override` / `alert_max_override` — per-sensor spec override; else inherits from `fusion.plating.bath.parameter.target_min/max`
|
||||
- Cached `last_reading_value` / `last_reading_at` / `last_reading_in_spec` for fast list views
|
||||
|
||||
**`fp.tank.reading`** — time-series log of every reading
|
||||
- Append-only — never updated/deleted. The compliance record of bath history.
|
||||
- `create()` evaluates each reading against the sensor's alert range
|
||||
- Raises a `fusion.plating.quality.hold` ONCE on the transition from in-spec → out-of-spec (no spam)
|
||||
|
||||
**`fusion.plating.tank`** — extended with `x_fc_sensor_ids` o2m + `x_fc_has_out_of_spec` bool for the tank form.
|
||||
|
||||
### Endpoint — `POST /fp/iot/ingest`
|
||||
|
||||
For sensors that skip the Pi proxy and POST directly over HTTP.
|
||||
|
||||
- Auth: `X-FP-IOT-Token` header OR `"token"` key in JSON body, compared to `ir.config_parameter[fusion_plating_iot.ingest_token]` using `hmac.compare_digest`
|
||||
- Seeded token value: `CHANGE-ME-AFTER-INSTALL` — **MUST be rotated immediately after install** via Settings → Technical → System Parameters
|
||||
- Payload: single `{device_serial, value, read_at}` OR batch `{readings: [...]}`
|
||||
- Response: 200 + `{ok: true, accepted: N}`, 401 on auth fail, 404 if device_serial unknown
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `iot` — the server-side Odoo IoT module (in this same folder, needs to be installed first)
|
||||
- `fusion_plating` — for `fusion.plating.tank` + `fusion.plating.bath.parameter`
|
||||
- `fusion_plating_quality` — for `fusion.plating.quality.hold`
|
||||
|
||||
### Not yet — Phase B (when Pi hardware arrives)
|
||||
|
||||
- DS18B20 handler module for `iot_drivers` (the Pi-side proxy)
|
||||
- Systemd service config for running `iot_drivers` on vanilla Raspberry Pi OS
|
||||
- Pi firmware README
|
||||
|
||||
## Deployment to entech (LXC 111)
|
||||
|
||||
```bash
|
||||
# 1. Sync all three modules
|
||||
rsync -av fusion_iot/iot_base/ pve-worker5:/tmp/iot_base/
|
||||
rsync -av fusion_iot/iot/ pve-worker5:/tmp/iot/
|
||||
rsync -av fusion_iot/fusion_plating_iot/ pve-worker5:/tmp/fpi/
|
||||
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c '
|
||||
mv /tmp/iot_base /mnt/extra-addons/custom/
|
||||
mv /tmp/iot /mnt/extra-addons/custom/
|
||||
mv /tmp/fpi /mnt/extra-addons/custom/fusion_plating_iot
|
||||
chown -R odoo:odoo /mnt/extra-addons/custom/iot_base /mnt/extra-addons/custom/iot /mnt/extra-addons/custom/fusion_plating_iot
|
||||
'"
|
||||
|
||||
# 2. Install modules (order matters)
|
||||
ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c \
|
||||
\"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -i iot_base,iot,fusion_plating_iot --stop-after-init\""
|
||||
|
||||
# 3. Verify
|
||||
# - Settings → Technical → IoT menu appears
|
||||
# - Plating → Operations → Sensors & Readings menu appears
|
||||
# - curl test against /fp/iot/ingest (see README)
|
||||
```
|
||||
|
||||
## Test commands
|
||||
|
||||
```bash
|
||||
# Set a known token
|
||||
odoo shell> env['ir.config_parameter'].set_param('fusion_plating_iot.ingest_token', 'test-secret-123')
|
||||
|
||||
# Create a sensor manually
|
||||
odoo shell> env['fp.tank.sensor'].create({
|
||||
'name': 'Test probe',
|
||||
'device_serial': '28-test000001',
|
||||
'device_kind': 'ds18b20',
|
||||
'tank_id': <some_tank.id>,
|
||||
'parameter_id': <temperature_param.id>,
|
||||
})
|
||||
|
||||
# POST a reading
|
||||
curl -X POST http://entech:8069/fp/iot/ingest \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-FP-IOT-Token: test-secret-123' \
|
||||
-d '{"device_serial":"28-test000001","value":87.3}'
|
||||
# → {"ok":true,"accepted":1}
|
||||
|
||||
# Simulate out-of-spec reading (assuming target_max=90)
|
||||
curl -X POST http://entech:8069/fp/iot/ingest \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-FP-IOT-Token: test-secret-123' \
|
||||
-d '{"device_serial":"28-test000001","value":95.0}'
|
||||
# → reading created + fusion.plating.quality.hold auto-raised
|
||||
```
|
||||
7
fusion_iot/fusion_plating_iot/__init__.py
Normal file
7
fusion_iot/fusion_plating_iot/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
56
fusion_iot/fusion_plating_iot/__manifest__.py
Normal file
56
fusion_iot/fusion_plating_iot/__manifest__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — IoT Integration',
|
||||
'version': '19.0.0.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Wire physical tank sensors to Fusion Plating — live '
|
||||
'temperature / chemistry readings with auto quality holds '
|
||||
'on out-of-spec.',
|
||||
'description': """
|
||||
Fusion Plating — IoT Integration
|
||||
================================
|
||||
|
||||
Bridges the generic `iot` module (IoT Box + device management) to
|
||||
plating-specific models:
|
||||
|
||||
* ``fp.tank.sensor`` — maps an ``iot.device`` to a
|
||||
``fusion.plating.tank`` (or a ``fusion.plating.bath``).
|
||||
* ``fp.tank.reading`` — time-series log of every sensor reading.
|
||||
* Auto-creates a ``fusion.plating.quality.hold`` when a reading
|
||||
falls outside the tank/bath's target range (per
|
||||
``fusion.plating.bath.parameter`` spec).
|
||||
|
||||
Supports both the Odoo-IoT proxy path (Pi running iot_drivers) AND
|
||||
a direct HTTP ingest path (``/fp/iot/ingest``) for sensors that
|
||||
skip the proxy and POST straight to Odoo with a shared secret.
|
||||
|
||||
Part of the Fusion Plating product family by Nexa Systems Inc.
|
||||
""",
|
||||
'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': [
|
||||
'iot',
|
||||
'fusion_plating',
|
||||
'fusion_plating_quality',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'views/fp_tank_sensor_views.xml',
|
||||
'views/fp_tank_reading_views.xml',
|
||||
'views/fusion_plating_tank_views.xml',
|
||||
'views/fp_iot_menu.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
fusion_iot/fusion_plating_iot/controllers/__init__.py
Normal file
1
fusion_iot/fusion_plating_iot/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import fp_iot_ingest
|
||||
143
fusion_iot/fusion_plating_iot/controllers/fp_iot_ingest.py
Normal file
143
fusion_iot/fusion_plating_iot/controllers/fp_iot_ingest.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Direct-HTTP ingest endpoint for sensors that bypass the Odoo IoT proxy.
|
||||
|
||||
Authentication: shared secret header `X-FP-IOT-Token` compared to the
|
||||
system parameter `fusion_plating_iot.ingest_token`. The Pi proxy (via
|
||||
iot_drivers) uses Odoo's built-in websocket and doesn't need this path.
|
||||
|
||||
Payload (JSON):
|
||||
{
|
||||
"device_serial": "28-abc123def456",
|
||||
"value": 87.3,
|
||||
"unit": "C", // informational, optional
|
||||
"read_at": "2026-04-19T13:12:05Z" // optional; defaults to now
|
||||
}
|
||||
|
||||
Or a batch form:
|
||||
{
|
||||
"token": "<shared secret>", // alternative to X-FP-IOT-Token
|
||||
"readings": [
|
||||
{"device_serial": "...", "value": ..., "read_at": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Returns 200 + `{ok: true, accepted: N}` on success, 401 on auth fail,
|
||||
404 if any device_serial isn't mapped to a fp.tank.sensor.
|
||||
"""
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_read_at(raw):
|
||||
"""Best-effort ISO-8601 parse — fall back to 'now' on garbage input."""
|
||||
from odoo.fields import Datetime as OdooDatetime
|
||||
if not raw:
|
||||
return OdooDatetime.now()
|
||||
try:
|
||||
# Accept both "2026-04-19T13:12:05Z" and "2026-04-19 13:12:05"
|
||||
s = raw.replace('Z', '+00:00')
|
||||
dt = datetime.fromisoformat(s)
|
||||
# Strip tz to store naive UTC, which is what Odoo Datetime fields store
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
except Exception:
|
||||
return OdooDatetime.now()
|
||||
|
||||
|
||||
class FpIotIngestController(http.Controller):
|
||||
|
||||
@http.route('/fp/iot/ingest', type='http', auth='public',
|
||||
methods=['POST'], csrf=False, save_session=False)
|
||||
def ingest(self, **_kwargs):
|
||||
"""Accept one-or-many sensor readings and land them in fp.tank.reading."""
|
||||
# Pull the shared secret from config — configured at install via
|
||||
# data/ir_config_parameter_data.xml, but admins can rotate it
|
||||
# in Settings → Technical → System Parameters.
|
||||
expected = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_plating_iot.ingest_token', ''
|
||||
)
|
||||
if not expected:
|
||||
_logger.warning('fp.iot.ingest: token not configured — all requests rejected')
|
||||
return Response(
|
||||
json.dumps({'ok': False, 'error': 'token_not_configured'}),
|
||||
status=503, content_type='application/json',
|
||||
)
|
||||
|
||||
# Accept token via either header or payload body — some simple
|
||||
# sensors can't easily set custom headers.
|
||||
header_token = request.httprequest.headers.get('X-FP-IOT-Token', '')
|
||||
raw = request.httprequest.get_data(as_text=True) or ''
|
||||
try:
|
||||
body = json.loads(raw) if raw else {}
|
||||
except ValueError:
|
||||
return Response(
|
||||
json.dumps({'ok': False, 'error': 'invalid_json'}),
|
||||
status=400, content_type='application/json',
|
||||
)
|
||||
body_token = body.get('token', '')
|
||||
presented = header_token or body_token
|
||||
if not hmac.compare_digest(str(presented), str(expected)):
|
||||
return Response(
|
||||
json.dumps({'ok': False, 'error': 'unauthorised'}),
|
||||
status=401, content_type='application/json',
|
||||
)
|
||||
|
||||
# Normalise payload to a list of readings.
|
||||
readings = body.get('readings')
|
||||
if readings is None:
|
||||
# Single-reading shortcut
|
||||
if 'device_serial' in body and 'value' in body:
|
||||
readings = [body]
|
||||
else:
|
||||
return Response(
|
||||
json.dumps({'ok': False, 'error': 'no_readings'}),
|
||||
status=400, content_type='application/json',
|
||||
)
|
||||
|
||||
Sensor = request.env['fp.tank.sensor'].sudo()
|
||||
Reading = request.env['fp.tank.reading'].sudo()
|
||||
accepted = 0
|
||||
unknown_serials = []
|
||||
for r in readings:
|
||||
serial = (r.get('device_serial') or '').strip()
|
||||
if not serial:
|
||||
continue
|
||||
sensor = Sensor.search([('device_serial', '=', serial)], limit=1)
|
||||
if not sensor:
|
||||
unknown_serials.append(serial)
|
||||
continue
|
||||
try:
|
||||
value = float(r.get('value'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
Reading.create({
|
||||
'sensor_id': sensor.id,
|
||||
'value': value,
|
||||
'reading_at': _parse_read_at(r.get('read_at')),
|
||||
'source': 'http_ingest',
|
||||
})
|
||||
accepted += 1
|
||||
|
||||
status = 200 if accepted else (404 if unknown_serials else 400)
|
||||
payload = {
|
||||
'ok': accepted > 0,
|
||||
'accepted': accepted,
|
||||
}
|
||||
if unknown_serials:
|
||||
payload['unknown_serials'] = unknown_serials
|
||||
return Response(
|
||||
json.dumps(payload),
|
||||
status=status, content_type='application/json',
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
|
||||
Seed the shared-secret token for /fp/iot/ingest. Admins MUST rotate
|
||||
this after install via Settings → Technical → System Parameters.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="fp_iot_ingest_token" model="ir.config_parameter">
|
||||
<field name="key">fusion_plating_iot.ingest_token</field>
|
||||
<field name="value">CHANGE-ME-AFTER-INSTALL</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
4
fusion_iot/fusion_plating_iot/models/__init__.py
Normal file
4
fusion_iot/fusion_plating_iot/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_tank_sensor
|
||||
from . import fp_tank_reading
|
||||
from . import fusion_plating_tank
|
||||
189
fusion_iot/fusion_plating_iot/models/fp_tank_reading.py
Normal file
189
fusion_iot/fusion_plating_iot/models/fp_tank_reading.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Time-series of sensor readings.
|
||||
|
||||
Every POST to /fp/iot/ingest (or every broadcast from the iot proxy)
|
||||
lands as a new row here. Kept intentionally append-only — we never
|
||||
update or delete readings, which makes this the compliance log for
|
||||
bath-temperature history.
|
||||
|
||||
Auto-creates a fusion.plating.quality.hold when a reading falls
|
||||
outside the sensor's alert range AND the sensor has
|
||||
`alert_on_out_of_spec` enabled. The hold is created once per
|
||||
excursion (we don't spam a new hold for every reading during a
|
||||
sustained excursion) — tracked via the sensor's most-recent
|
||||
`last_reading_in_spec` flag.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpTankReading(models.Model):
|
||||
_name = 'fp.tank.reading'
|
||||
_description = 'Fusion Plating — Tank Sensor Reading'
|
||||
_order = 'reading_at desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
sensor_id = fields.Many2one(
|
||||
'fp.tank.sensor', string='Sensor', required=True,
|
||||
ondelete='cascade', index=True,
|
||||
)
|
||||
# Denormalised for fast list views + kpi queries — auto-filled at
|
||||
# create time from sensor_id. Indexed for historical trending.
|
||||
tank_id = fields.Many2one(
|
||||
'fusion.plating.tank', string='Tank',
|
||||
related='sensor_id.tank_id', store=True, index=True,
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath',
|
||||
related='sensor_id.bath_id', store=True,
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter',
|
||||
related='sensor_id.parameter_id', store=True,
|
||||
)
|
||||
|
||||
reading_at = fields.Datetime(
|
||||
string='Read At', required=True,
|
||||
default=fields.Datetime.now, index=True,
|
||||
)
|
||||
value = fields.Float(
|
||||
string='Value', required=True, digits=(12, 4),
|
||||
help='Numeric reading in the parameter\'s native unit (°C, pH, '
|
||||
'µS/cm, etc.).',
|
||||
)
|
||||
unit = fields.Char(
|
||||
string='Unit', related='parameter_id.uom', store=True,
|
||||
)
|
||||
|
||||
source = fields.Selection(
|
||||
[
|
||||
('iot_proxy', 'IoT Proxy (Pi)'),
|
||||
('http_ingest', 'HTTP Ingest (direct)'),
|
||||
('manual', 'Manual Entry'),
|
||||
],
|
||||
string='Source', default='http_ingest', required=True,
|
||||
)
|
||||
in_spec = fields.Boolean(
|
||||
string='In Spec', readonly=True,
|
||||
help='Whether this reading fell within the sensor\'s alert range.',
|
||||
)
|
||||
hold_id = fields.Many2one(
|
||||
'fusion.plating.quality.hold', string='Resulting Hold',
|
||||
ondelete='set null', readonly=True,
|
||||
help='The quality hold auto-created by this reading, if any. '
|
||||
'Only the FIRST out-of-spec reading in an excursion creates '
|
||||
'a hold; subsequent readings during the same excursion do '
|
||||
'not duplicate.',
|
||||
)
|
||||
display_name = fields.Char(
|
||||
string='Display', compute='_compute_display_name', store=True,
|
||||
)
|
||||
|
||||
@api.depends('sensor_id', 'value', 'reading_at')
|
||||
def _compute_display_name(self):
|
||||
for r in self:
|
||||
sensor = r.sensor_id.name or 'sensor'
|
||||
at = fields.Datetime.to_string(r.reading_at) if r.reading_at else ''
|
||||
unit = r.unit or ''
|
||||
r.display_name = f'{sensor} — {r.value:.2f} {unit} @ {at}'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create hook — evaluate against spec + raise a quality hold if we
|
||||
# just crossed INTO an out-of-spec state.
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
try:
|
||||
rec._evaluate_spec()
|
||||
except Exception:
|
||||
# Never let alert-logic break the ingest path — the
|
||||
# reading itself is what matters for compliance. Log
|
||||
# and carry on.
|
||||
_logger.exception(
|
||||
'fp.tank.reading alert eval failed for reading %s', rec.id,
|
||||
)
|
||||
return records
|
||||
|
||||
def _evaluate_spec(self):
|
||||
"""Set `in_spec`, update sensor cache, raise hold if this reading
|
||||
is the first out-of-spec reading of a new excursion.
|
||||
"""
|
||||
self.ensure_one()
|
||||
sensor = self.sensor_id
|
||||
lo, hi = sensor._get_alert_range()
|
||||
# Zero-bounded checks: a 0 value means "no bound defined"
|
||||
ok_lo = (lo == 0.0) or (self.value >= lo)
|
||||
ok_hi = (hi == 0.0) or (self.value <= hi)
|
||||
in_spec = ok_lo and ok_hi
|
||||
self.in_spec = in_spec
|
||||
|
||||
# Track excursion transitions on the sensor so we only fire ONE
|
||||
# hold per out-of-spec episode, not one per reading.
|
||||
previously_in_spec = sensor.last_reading_in_spec
|
||||
sensor.sudo().write({
|
||||
'last_reading_value': self.value,
|
||||
'last_reading_at': self.reading_at,
|
||||
'last_reading_in_spec': in_spec,
|
||||
})
|
||||
|
||||
# Crossed from in-spec → out-of-spec on this reading
|
||||
newly_excursion = (previously_in_spec and not in_spec)
|
||||
first_reading_and_bad = (sensor.reading_count == 1 and not in_spec)
|
||||
if (newly_excursion or first_reading_and_bad) and sensor.alert_on_out_of_spec:
|
||||
self._raise_quality_hold()
|
||||
|
||||
def _raise_quality_hold(self):
|
||||
"""Create a quality hold describing the out-of-spec reading."""
|
||||
self.ensure_one()
|
||||
Hold = self.env.get('fusion.plating.quality.hold')
|
||||
if Hold is None:
|
||||
return # quality module not installed
|
||||
sensor = self.sensor_id
|
||||
lo, hi = sensor._get_alert_range()
|
||||
parts = [
|
||||
f'Sensor {sensor.name!r} reading {self.value:.2f} '
|
||||
f'{self.unit or ""} is out of spec.',
|
||||
f'Target range: {lo:.2f} .. {hi:.2f}.',
|
||||
]
|
||||
if sensor.tank_id:
|
||||
parts.append(f'Tank: {sensor.tank_id.name}.')
|
||||
if sensor.bath_id:
|
||||
parts.append(f'Bath: {sensor.bath_id.name}.')
|
||||
description = ' '.join(parts)
|
||||
|
||||
hold_vals = {
|
||||
'hold_reason': 'out_of_spec',
|
||||
'description': description,
|
||||
'qty_on_hold': 1,
|
||||
# state defaults to 'on_hold' — leave it
|
||||
}
|
||||
# Attach facility + work-centre context if the tank has them,
|
||||
# so the hold is actionable from the shop floor (operator can
|
||||
# navigate back to the tank from the hold record).
|
||||
if sensor.tank_id:
|
||||
if 'facility_id' in Hold._fields:
|
||||
tank_facility = getattr(sensor.tank_id, 'facility_id', None)
|
||||
if tank_facility:
|
||||
hold_vals['facility_id'] = tank_facility.id
|
||||
if 'part_ref' in Hold._fields:
|
||||
hold_vals['part_ref'] = f'Tank {sensor.tank_id.name} bath'
|
||||
try:
|
||||
hold = Hold.sudo().create(hold_vals)
|
||||
self.hold_id = hold.id
|
||||
_logger.info(
|
||||
'fp.tank.reading %s triggered quality hold %s (%.2f %s out of %.2f..%.2f)',
|
||||
self.id, hold.id, self.value, self.unit or '', lo, hi,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Could not create quality hold for reading %s', self.id,
|
||||
)
|
||||
151
fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py
Normal file
151
fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Sensor → tank mapping.
|
||||
|
||||
One physical sensor (a DS18B20 probe, a MAX31865 RTD, or any future
|
||||
device registered via the iot_drivers proxy) is mapped to exactly one
|
||||
tank or bath and measures ONE bath parameter (temperature, pH,
|
||||
conductivity, etc.).
|
||||
|
||||
The same tank can carry multiple sensors — e.g. a temp probe and a pH
|
||||
probe. Each is its own fp.tank.sensor row.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpTankSensor(models.Model):
|
||||
_name = 'fp.tank.sensor'
|
||||
_description = 'Fusion Plating — Tank Sensor'
|
||||
_order = 'tank_id, parameter_id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Sensor Name', required=True,
|
||||
help='Human label (e.g. "Tank 3 — ENP temp").',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Physical device — either an Odoo iot.device (proxied through the Pi)
|
||||
# OR a direct-ingest sensor (skipping the proxy, posting straight to
|
||||
# /fp/iot/ingest with the shared secret + device_serial).
|
||||
# ------------------------------------------------------------------
|
||||
iot_device_id = fields.Many2one(
|
||||
'iot.device', string='IoT Device', ondelete='set null',
|
||||
help='The iot.device record as registered by the Pi proxy. '
|
||||
'Leave empty for direct-HTTP-ingest sensors.',
|
||||
)
|
||||
device_serial = fields.Char(
|
||||
string='Device Serial', index=True,
|
||||
help='Hardware unique ID (e.g. DS18B20 1-Wire address '
|
||||
'"28-abc123def456"). Used by /fp/iot/ingest to route '
|
||||
'posted readings to the right sensor.',
|
||||
)
|
||||
device_kind = fields.Selection(
|
||||
[
|
||||
('ds18b20', 'DS18B20 temperature'),
|
||||
('pt100', 'PT100 RTD temperature'),
|
||||
('pt1000', 'PT1000 RTD temperature'),
|
||||
('ph', 'pH probe'),
|
||||
('conductivity','Conductivity probe'),
|
||||
('level', 'Level sensor'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Sensor Type', default='ds18b20', required=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Where this sensor lives + what it measures
|
||||
# ------------------------------------------------------------------
|
||||
tank_id = fields.Many2one(
|
||||
'fusion.plating.tank', string='Tank', ondelete='cascade',
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath',
|
||||
help='Optional — if the sensor is bound to a specific bath '
|
||||
'chemistry rather than a physical tank.',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter Measured',
|
||||
required=True,
|
||||
help='Which bath parameter this sensor reports (temperature, pH, '
|
||||
'etc.). Drives unit labelling + out-of-spec alerting against '
|
||||
'the parameter\'s target_min / target_max.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Alerting behaviour
|
||||
# ------------------------------------------------------------------
|
||||
alert_on_out_of_spec = fields.Boolean(
|
||||
string='Alert on Out-of-Spec', default=True,
|
||||
help='If checked, a fusion.plating.quality.hold is auto-created '
|
||||
'when a reading falls outside the parameter target range.',
|
||||
)
|
||||
alert_min_override = fields.Float(
|
||||
string='Alert Min (override)', digits=(10, 4),
|
||||
help='Optional override of the parameter\'s target_min for this '
|
||||
'specific sensor. Leave 0 to inherit from bath.parameter.',
|
||||
)
|
||||
alert_max_override = fields.Float(
|
||||
string='Alert Max (override)', digits=(10, 4),
|
||||
help='Optional override of the parameter\'s target_max for this '
|
||||
'specific sensor.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cached latest-reading fields (for quick display in list views)
|
||||
# ------------------------------------------------------------------
|
||||
last_reading_value = fields.Float(
|
||||
string='Latest Value', readonly=True, digits=(12, 4),
|
||||
)
|
||||
last_reading_at = fields.Datetime(string='Latest Reading', readonly=True)
|
||||
last_reading_in_spec = fields.Boolean(
|
||||
string='In Spec?', readonly=True,
|
||||
help='Computed from the last reading vs alert_min/alert_max.',
|
||||
)
|
||||
|
||||
reading_ids = fields.One2many(
|
||||
'fp.tank.reading', 'sensor_id', string='Reading History',
|
||||
)
|
||||
reading_count = fields.Integer(
|
||||
string='Readings', compute='_compute_reading_count',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_tank_sensor_serial_uniq',
|
||||
'unique(device_serial)',
|
||||
'Each hardware serial can only be mapped to one sensor.'),
|
||||
]
|
||||
|
||||
@api.depends('reading_ids')
|
||||
def _compute_reading_count(self):
|
||||
for rec in self:
|
||||
rec.reading_count = len(rec.reading_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Resolve effective alert range — override wins, else bath.parameter
|
||||
# ------------------------------------------------------------------
|
||||
def _get_alert_range(self):
|
||||
"""Return (min, max) floats. Zero means 'no bound'."""
|
||||
self.ensure_one()
|
||||
lo = self.alert_min_override or (
|
||||
self.parameter_id.target_min if self.parameter_id else 0.0
|
||||
)
|
||||
hi = self.alert_max_override or (
|
||||
self.parameter_id.target_max if self.parameter_id else 0.0
|
||||
)
|
||||
return (lo or 0.0, hi or 0.0)
|
||||
|
||||
def action_view_readings(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Readings — {self.name}',
|
||||
'res_model': 'fp.tank.reading',
|
||||
'view_mode': 'list,form,graph',
|
||||
'domain': [('sensor_id', '=', self.id)],
|
||||
'context': {'default_sensor_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
33
fusion_iot/fusion_plating_iot/models/fusion_plating_tank.py
Normal file
33
fusion_iot/fusion_plating_iot/models/fusion_plating_tank.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Lightweight extension of fusion.plating.tank to surface its mapped
|
||||
sensors + latest reading state inline on the tank form.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionPlatingTank(models.Model):
|
||||
_inherit = 'fusion.plating.tank'
|
||||
|
||||
x_fc_sensor_ids = fields.One2many(
|
||||
'fp.tank.sensor', 'tank_id', string='Sensors',
|
||||
)
|
||||
x_fc_sensor_count = fields.Integer(
|
||||
string='Sensor Count', compute='_compute_sensor_stats',
|
||||
)
|
||||
x_fc_has_out_of_spec = fields.Boolean(
|
||||
string='Any Sensor Out of Spec', compute='_compute_sensor_stats',
|
||||
help='True if ANY mapped sensor\'s latest reading is out of spec.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_sensor_ids.last_reading_in_spec',
|
||||
'x_fc_sensor_ids.last_reading_at')
|
||||
def _compute_sensor_stats(self):
|
||||
for tank in self:
|
||||
live = tank.x_fc_sensor_ids.filtered(lambda s: s.last_reading_at)
|
||||
tank.x_fc_sensor_count = len(tank.x_fc_sensor_ids)
|
||||
tank.x_fc_has_out_of_spec = any(
|
||||
not s.last_reading_in_spec for s in live
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
fp_tank_sensor_operator,fp.tank.sensor operator,model_fp_tank_sensor,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
fp_tank_sensor_supervisor,fp.tank.sensor supervisor,model_fp_tank_sensor,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
fp_tank_sensor_manager,fp.tank.sensor manager,model_fp_tank_sensor,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
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_manager,fp.tank.reading manager,model_fp_tank_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
29
fusion_iot/fusion_plating_iot/views/fp_iot_menu.xml
Normal file
29
fusion_iot/fusion_plating_iot/views/fp_iot_menu.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
|
||||
Surface IoT sensors + readings under the existing Plating >
|
||||
Operations menu. Not a top-level app — sensors are an extension
|
||||
of bath/tank management, not a separate concern.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_fp_iot_root"
|
||||
name="Sensors & Readings"
|
||||
parent="fusion_plating.menu_fp_operations"
|
||||
sequence="55"/>
|
||||
|
||||
<menuitem id="menu_fp_tank_sensor"
|
||||
name="Tank Sensors"
|
||||
parent="menu_fp_iot_root"
|
||||
action="action_fp_tank_sensor"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_fp_tank_reading"
|
||||
name="Sensor Readings"
|
||||
parent="menu_fp_iot_root"
|
||||
action="action_fp_tank_reading"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="fp_tank_reading_list" model="ir.ui.view">
|
||||
<field name="name">fp.tank.reading.list</field>
|
||||
<field name="model">fp.tank.reading</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sensor Readings"
|
||||
decoration-danger="not in_spec" default_order="reading_at desc">
|
||||
<field name="reading_at"/>
|
||||
<field name="sensor_id"/>
|
||||
<field name="tank_id" optional="show"/>
|
||||
<field name="parameter_id" optional="hide"/>
|
||||
<field name="value"/>
|
||||
<field name="unit"/>
|
||||
<field name="in_spec" widget="boolean_toggle"/>
|
||||
<field name="source" optional="hide"/>
|
||||
<field name="hold_id" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_tank_reading_form" model="ir.ui.view">
|
||||
<field name="name">fp.tank.reading.form</field>
|
||||
<field name="model">fp.tank.reading</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sensor Reading" create="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sensor_id"/>
|
||||
<field name="tank_id" readonly="1"/>
|
||||
<field name="parameter_id" readonly="1"/>
|
||||
<field name="source" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reading_at"/>
|
||||
<field name="value"/>
|
||||
<field name="unit" readonly="1"/>
|
||||
<field name="in_spec" readonly="1"/>
|
||||
<field name="hold_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_tank_reading_graph" model="ir.ui.view">
|
||||
<field name="name">fp.tank.reading.graph</field>
|
||||
<field name="model">fp.tank.reading</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Readings Trend" type="line">
|
||||
<field name="reading_at" interval="hour"/>
|
||||
<field name="value" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_tank_reading_search" model="ir.ui.view">
|
||||
<field name="name">fp.tank.reading.search</field>
|
||||
<field name="model">fp.tank.reading</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="sensor_id"/>
|
||||
<field name="tank_id"/>
|
||||
<field name="parameter_id"/>
|
||||
<filter name="out_of_spec" string="Out of Spec"
|
||||
domain="[('in_spec', '=', False)]"/>
|
||||
<filter name="today" string="Today"
|
||||
domain="[('reading_at', '>=', (context_today()).strftime('%Y-%m-%d'))]"/>
|
||||
<filter name="last_24h" string="Last 24h"
|
||||
domain="[('reading_at', '>=', (datetime.datetime.now() - datetime.timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
|
||||
<group>
|
||||
<filter name="by_sensor" string="Sensor"
|
||||
context="{'group_by': 'sensor_id'}"/>
|
||||
<filter name="by_tank" string="Tank"
|
||||
context="{'group_by': 'tank_id'}"/>
|
||||
<filter name="by_day" string="Day"
|
||||
context="{'group_by': 'reading_at:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_tank_reading" model="ir.actions.act_window">
|
||||
<field name="name">Sensor Readings</field>
|
||||
<field name="res_model">fp.tank.reading</field>
|
||||
<field name="view_mode">list,graph,form</field>
|
||||
<field name="search_view_id" ref="fp_tank_reading_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
126
fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml
Normal file
126
fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml
Normal file
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== List ===== -->
|
||||
<record id="fp_tank_sensor_list" model="ir.ui.view">
|
||||
<field name="name">fp.tank.sensor.list</field>
|
||||
<field name="model">fp.tank.sensor</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Tank Sensors" decoration-danger="not last_reading_in_spec and last_reading_at"
|
||||
decoration-muted="not active">
|
||||
<field name="name"/>
|
||||
<field name="device_kind"/>
|
||||
<field name="tank_id"/>
|
||||
<field name="bath_id" optional="show"/>
|
||||
<field name="parameter_id"/>
|
||||
<field name="device_serial" optional="show"/>
|
||||
<field name="iot_device_id" optional="hide"/>
|
||||
<field name="last_reading_value"/>
|
||||
<field name="last_reading_at"/>
|
||||
<field name="last_reading_in_spec" widget="boolean_toggle"/>
|
||||
<field name="reading_count"/>
|
||||
<field name="active" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Form ===== -->
|
||||
<record id="fp_tank_sensor_form" model="ir.ui.view">
|
||||
<field name="name">fp.tank.sensor.form</field>
|
||||
<field name="model">fp.tank.sensor</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Tank Sensor">
|
||||
<header/>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_readings" type="object"
|
||||
class="oe_stat_button" icon="fa-line-chart">
|
||||
<field name="reading_count" widget="statinfo"
|
||||
string="Readings"/>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="In Spec"
|
||||
invisible="not last_reading_in_spec or not last_reading_at"
|
||||
bg_color="text-bg-success"/>
|
||||
<widget name="web_ribbon" title="OUT OF SPEC"
|
||||
invisible="last_reading_in_spec or not last_reading_at"
|
||||
bg_color="text-bg-danger"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Tank 3 — ENP temp"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Hardware">
|
||||
<field name="device_kind"/>
|
||||
<field name="device_serial" placeholder="28-abc123def456"/>
|
||||
<field name="iot_device_id"
|
||||
options="{'no_create': True}"
|
||||
help="Optional — the iot.device auto-registered by the Pi proxy."/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Location">
|
||||
<field name="tank_id" options="{'no_create': True}"/>
|
||||
<field name="bath_id" options="{'no_create': True}"/>
|
||||
<field name="parameter_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Alerting">
|
||||
<group>
|
||||
<field name="alert_on_out_of_spec"/>
|
||||
<field name="alert_min_override"
|
||||
help="Leave 0 to inherit from the bath parameter's target_min."/>
|
||||
<field name="alert_max_override"
|
||||
help="Leave 0 to inherit from the bath parameter's target_max."/>
|
||||
</group>
|
||||
<group string="Most Recent Reading">
|
||||
<field name="last_reading_value" readonly="1"/>
|
||||
<field name="last_reading_at" readonly="1"/>
|
||||
<field name="last_reading_in_spec" readonly="1" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Search ===== -->
|
||||
<record id="fp_tank_sensor_search" model="ir.ui.view">
|
||||
<field name="name">fp.tank.sensor.search</field>
|
||||
<field name="model">fp.tank.sensor</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="device_serial"/>
|
||||
<field name="tank_id"/>
|
||||
<field name="parameter_id"/>
|
||||
<filter name="out_of_spec" string="Out of Spec"
|
||||
domain="[('last_reading_in_spec', '=', False),
|
||||
('last_reading_at', '!=', False)]"/>
|
||||
<filter name="alerting_on" string="Alerting Enabled"
|
||||
domain="[('alert_on_out_of_spec', '=', True)]"/>
|
||||
<filter name="inactive" string="Archived"
|
||||
domain="[('active', '=', False)]"/>
|
||||
<group>
|
||||
<filter name="by_tank" string="Tank"
|
||||
context="{'group_by': 'tank_id'}"/>
|
||||
<filter name="by_parameter" string="Parameter"
|
||||
context="{'group_by': 'parameter_id'}"/>
|
||||
<filter name="by_kind" string="Sensor Type"
|
||||
context="{'group_by': 'device_kind'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Action ===== -->
|
||||
<record id="action_fp_tank_sensor" model="ir.actions.act_window">
|
||||
<field name="name">Tank Sensors</field>
|
||||
<field name="res_model">fp.tank.sensor</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="fp_tank_sensor_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
|
||||
Surface IoT sensors inline on the existing fusion.plating.tank form
|
||||
so the bath operator sees live sensor status in context, not in a
|
||||
separate app.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="fusion_plating_tank_form_iot_inherit" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.tank.form.iot</field>
|
||||
<field name="model">fusion.plating.tank</field>
|
||||
<field name="inherit_id" ref="fusion_plating.view_fp_tank_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<notebook>
|
||||
<page string="Sensors" name="iot_sensors">
|
||||
<field name="x_fc_sensor_ids" context="{'default_tank_id': id}">
|
||||
<list editable="bottom"
|
||||
decoration-danger="not last_reading_in_spec and last_reading_at">
|
||||
<field name="name"/>
|
||||
<field name="device_kind"/>
|
||||
<field name="device_serial"/>
|
||||
<field name="parameter_id"/>
|
||||
<field name="last_reading_value"/>
|
||||
<field name="last_reading_at" readonly="1"/>
|
||||
<field name="last_reading_in_spec" widget="boolean_toggle"/>
|
||||
<field name="alert_on_out_of_spec" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
BIN
fusion_iot/iot/.___init__.py
Normal file
BIN
fusion_iot/iot/.___init__.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/.___manifest__.py
Normal file
BIN
fusion_iot/iot/.___manifest__.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/._controllers
Executable file
BIN
fusion_iot/iot/._controllers
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._demo
Executable file
BIN
fusion_iot/iot/._demo
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._i18n
Executable file
BIN
fusion_iot/iot/._i18n
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._iot_handlers
Executable file
BIN
fusion_iot/iot/._iot_handlers
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._models
Executable file
BIN
fusion_iot/iot/._models
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._security
Executable file
BIN
fusion_iot/iot/._security
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._static
Executable file
BIN
fusion_iot/iot/._static
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._tests
Executable file
BIN
fusion_iot/iot/._tests
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._views
Executable file
BIN
fusion_iot/iot/._views
Executable file
Binary file not shown.
BIN
fusion_iot/iot/._wizard
Executable file
BIN
fusion_iot/iot/._wizard
Executable file
Binary file not shown.
6
fusion_iot/iot/__init__.py
Normal file
6
fusion_iot/iot/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizard
|
||||
52
fusion_iot/iot/__manifest__.py
Normal file
52
fusion_iot/iot/__manifest__.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Repackaged for Fusion Apps by Nexa Systems Inc. (2026) — LGPL-3.
|
||||
# Upstream source: Odoo S.A. `iot` module (tag 19.0).
|
||||
# Changes from upstream:
|
||||
# * update.py — publisher_warranty IoT-Box reporter neutralised
|
||||
# * iot_handlers/lib/load_worldline_library.sh — removed (Worldline lib fetch from odoo.com)
|
||||
# No other functional changes — the module still runs Odoo's IoT pairing,
|
||||
# channel, device management UI, and handler-zip endpoint as upstream.
|
||||
|
||||
{
|
||||
'name': 'Internet of Things',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Administration/IoT',
|
||||
'sequence': 250,
|
||||
'summary': 'IoT Box management + device framework (repackaged for Fusion).',
|
||||
'description': """
|
||||
This module provides management of your IoT Boxes inside Odoo.
|
||||
|
||||
Repackaged for community use by Nexa Systems Inc. — Fusion Apps product family.
|
||||
""",
|
||||
'depends': ['mail', 'iot_base'],
|
||||
'data': [
|
||||
'wizard/add_iot_box_views.xml',
|
||||
'wizard/select_printers_views.xml',
|
||||
'security/iot_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/iot_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'demo/iot_demo.xml'
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'author': 'Nexa Systems Inc. (repackaged from Odoo S.A.)',
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'iot/static/src/**/*',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'iot/static/src/network_utils/iot_websocket.js',
|
||||
'iot/static/src/network_utils/iot_webrtc.js',
|
||||
'iot/static/tests/unit/**/*',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
('include', 'iot.assets_tests'),
|
||||
],
|
||||
'iot.assets_tests': [
|
||||
'iot/static/tests/tours/**/*',
|
||||
],
|
||||
}
|
||||
}
|
||||
BIN
fusion_iot/iot/controllers/.___init__.py
Normal file
BIN
fusion_iot/iot/controllers/.___init__.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/controllers/._main.py
Normal file
BIN
fusion_iot/iot/controllers/._main.py
Normal file
Binary file not shown.
4
fusion_iot/iot/controllers/__init__.py
Normal file
4
fusion_iot/iot/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
325
fusion_iot/iot/controllers/main.py
Normal file
325
fusion_iot/iot/controllers/main.py
Normal file
@@ -0,0 +1,325 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import pprint
|
||||
import textwrap
|
||||
import werkzeug
|
||||
import zipfile
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response, Stream
|
||||
from odoo.modules import get_module_path
|
||||
from odoo.tools.misc import str2bool
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_iot_logger = logging.getLogger(__name__ + '.iot_log')
|
||||
# We want to catch any log level that the IoT send
|
||||
_iot_logger.setLevel(logging.DEBUG)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_unique_name(name):
|
||||
existing_names = request.env['iot.box'].sudo().search([('name', 'ilike', name + '%')]).mapped('name')
|
||||
base_name = name
|
||||
suffix = 1
|
||||
while name in existing_names:
|
||||
name = f"{base_name} ({suffix})"
|
||||
suffix += 1
|
||||
|
||||
return name
|
||||
|
||||
|
||||
class IoTController(http.Controller):
|
||||
def _search_box(self, identifier):
|
||||
return request.env['iot.box'].sudo().search([('identifier', '=', identifier)], limit=1)
|
||||
|
||||
@http.route('/iot/get_handlers', type='http', auth='public', csrf=False)
|
||||
def get_handlers(self, identifier, auto):
|
||||
"""Return a zip file containing all the IoT handlers for the given IoT Box.
|
||||
|
||||
:param identifier: The identifier of the IoT Box.
|
||||
:param auto: If True, the IoT Box will automatically update its handlers.
|
||||
:return: A zip file containing all the IoT handlers.
|
||||
"""
|
||||
# Check if identifier is of one of the IoT Boxes
|
||||
box = self._search_box(identifier)
|
||||
if not box or (auto == 'True' and not box.drivers_auto_update):
|
||||
raise werkzeug.exceptions.Unauthorized(
|
||||
description="No IoT box found with identifier '%s' or auto update disabled on the box." % identifier
|
||||
)
|
||||
|
||||
# '_L.py' files for Linux and '_W.py' for Windows
|
||||
incompatible_filename = "_L.py" if box.version[0] == 'W' else "_W.py"
|
||||
module_ids = request.env['ir.module.module'].sudo().search([('state', '=', 'installed')])
|
||||
fobj = io.BytesIO()
|
||||
with zipfile.ZipFile(fobj, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for module in module_ids.mapped('name') + ['iot_drivers', 'pos_blackbox_be']: # add pos_blackbox_be to detect blackbox devices without the module installed
|
||||
module_path = get_module_path(module)
|
||||
if module_path:
|
||||
iot_handlers = pathlib.Path(module_path) / 'iot_handlers'
|
||||
for handler in iot_handlers.glob('*/*'):
|
||||
if handler.name.startswith(('.', '_')) or handler.name.endswith(incompatible_filename):
|
||||
continue
|
||||
zf.write(handler, handler.relative_to(iot_handlers)) # In order to remove the absolute path
|
||||
|
||||
etag = hashlib.sha256(fobj.getvalue()).hexdigest()
|
||||
# If the file has not been modified since the last request, return a 304 (Not Modified)
|
||||
if etag == request.httprequest.headers.get('If-None-Match'):
|
||||
return request.make_response('', headers=[('ETag', etag)], status=304)
|
||||
|
||||
return Stream(
|
||||
type='data',
|
||||
data=fobj.getvalue(),
|
||||
download_name='iot_handlers.zip',
|
||||
etag=etag,
|
||||
size=fobj.tell(),
|
||||
public=True,
|
||||
).get_response()
|
||||
|
||||
@http.route('/iot/keyboard_layouts', type='http', auth='public', csrf=False)
|
||||
def load_keyboard_layouts(self, available_layouts):
|
||||
if not request.env['iot.keyboard.layout'].sudo().search_count([]):
|
||||
request.env['iot.keyboard.layout'].sudo().create(json.loads(available_layouts))
|
||||
return ''
|
||||
|
||||
@http.route('/iot/box/<string:identifier>/display_url', type='http', auth='public')
|
||||
def get_url(self, identifier):
|
||||
urls = {}
|
||||
iotbox = self._search_box(identifier)
|
||||
if iotbox:
|
||||
iot_devices = iotbox.device_ids.filtered(lambda device: device.type == 'display')
|
||||
for device in iot_devices:
|
||||
urls[device.identifier] = device.display_url
|
||||
return json.dumps(urls)
|
||||
|
||||
@http.route('/iot/box/send_websocket', type='jsonrpc', auth='public')
|
||||
def iot_box_send_websocket(self, session_id, iot_box_identifier, device_identifier, status, **kwargs):
|
||||
"""Called by the IoT Box once an operation is over. We then forward
|
||||
the acknowledgment to the user who made the request to inform him
|
||||
of the success of the operation.
|
||||
|
||||
:param session_id: ID of the operation
|
||||
:param iot_box_identifier: The IP of the IoT box (used to find the box)
|
||||
:param device_identifier: The IoT device identifier
|
||||
:param status: Status of the last action (success, error, ...)
|
||||
:param kwargs:
|
||||
"""
|
||||
box = self._search_box(iot_box_identifier)
|
||||
if not box:
|
||||
_logger.warning("No IoT Box found with identifier: '%s'. Request ignored", iot_box_identifier)
|
||||
return
|
||||
|
||||
if (
|
||||
device_identifier
|
||||
and not request.env["iot.device"].sudo().search(
|
||||
[('identifier', '=', device_identifier), ('iot_id', '=', box.id)], limit=1
|
||||
)
|
||||
and device_identifier != box.identifier # target the box itself
|
||||
):
|
||||
_logger.warning(
|
||||
"No IoT device found with identifier '%s' (iot_box_identifier: %s). Request ignored",
|
||||
device_identifier, iot_box_identifier
|
||||
)
|
||||
return
|
||||
|
||||
request.env['iot.channel'].send_message({
|
||||
'session_id': session_id or kwargs.get("owner"), # TODO: replace "owner" by "session_id" in drivers
|
||||
'iot_box_identifier': iot_box_identifier,
|
||||
'device_identifier': device_identifier,
|
||||
'message': {
|
||||
'status': status,
|
||||
'result': kwargs.get('result', {}),
|
||||
'action_args': kwargs.get('action_args', {})
|
||||
},
|
||||
}, message_type='operation_confirmation')
|
||||
|
||||
@http.route('/iot/box/webrtc_answer', type='jsonrpc', auth='public')
|
||||
def iot_box_webrtc_answer(self, iot_box_identifier, answer):
|
||||
"""Called by the IoT Box after receiving a WebRTC offer from a user.
|
||||
The IoT box sends its WebRTC answer and we forward it to the user so
|
||||
they can establish the connection.
|
||||
|
||||
:param iot_box_identifier: The identifier (serial number) of the IoT box
|
||||
:param answer: The WebRTC answer object
|
||||
"""
|
||||
box = self._search_box(iot_box_identifier)
|
||||
if not box:
|
||||
_logger.warning("No IoT Box found with identifier: '%s'. Request ignored", iot_box_identifier)
|
||||
raise NotFound()
|
||||
|
||||
request.env['iot.channel'].send_message({
|
||||
'iot_box_identifier': iot_box_identifier,
|
||||
'answer': answer,
|
||||
}, message_type='webrtc_answer')
|
||||
|
||||
@http.route('/iot/setup', type='jsonrpc', auth='public')
|
||||
def update_box(self, iot_box, devices):
|
||||
"""This function receives a dict from the iot box with information from it
|
||||
as well as devices connected and supported by this box.
|
||||
This function create the box and the devices and set the status (connected / disconnected)
|
||||
of devices linked with this box
|
||||
|
||||
:param dict iot_box: IoT Box information
|
||||
:param dict devices: IoT devices information
|
||||
:return: IoT websocket channel
|
||||
"""
|
||||
# Update or create box
|
||||
iot_identifier = iot_box['identifier'] # IoT Mac Address
|
||||
new_iot_ip = iot_box['ip']
|
||||
new_iot_version = iot_box['version']
|
||||
box = self._search_box(iot_identifier)
|
||||
create_update_value = {
|
||||
'ip': new_iot_ip,
|
||||
'version': new_iot_version,
|
||||
}
|
||||
if box:
|
||||
if (box.ip, box.version) != (new_iot_ip, new_iot_version):
|
||||
_logger.info('Updating IoT %s with data: %s', box, create_update_value)
|
||||
box.write(create_update_value)
|
||||
else:
|
||||
name = 'IoT Box' if new_iot_version.startswith('L') else 'Virtual IoT Box'
|
||||
create_update_value['name'] = ensure_unique_name(name)
|
||||
icp_sudo = request.env['ir.config_parameter'].sudo()
|
||||
iot_token = icp_sudo.get_param('iot.iot_token')
|
||||
if iot_token and iot_token == iot_box['token']:
|
||||
create_update_value['identifier'] = iot_identifier
|
||||
_logger.info('Creating IoT with data: %s', create_update_value)
|
||||
box = request.env['iot.box'].sudo().create(create_update_value)
|
||||
|
||||
# Clear the used token to force creating a new one for next IoT Box
|
||||
icp_sudo.set_param('iot.iot_token', '')
|
||||
else:
|
||||
_logger.warning('Token mismatch for IoT %s expected %s got %s', iot_identifier, iot_token, iot_box['token'])
|
||||
return None
|
||||
|
||||
_logger.info('IoT %s devices:\n%s', box, pprint.pformat(devices))
|
||||
# Update or create devices
|
||||
if box:
|
||||
previously_connected_iot_devices = request.env['iot.device'].sudo().search([
|
||||
('iot_id', '=', box.id),
|
||||
('connected_status', '=', 'connected')
|
||||
])
|
||||
connected_iot_devices = request.env['iot.device'].sudo()
|
||||
for device_identifier in devices:
|
||||
available_types = [s[0] for s in request.env['iot.device']._fields['type'].selection]
|
||||
available_connections = [s[0] for s in request.env['iot.device']._fields['connection'].selection]
|
||||
|
||||
data_device = devices[device_identifier]
|
||||
if data_device['type'] in available_types and data_device['connection'] in available_connections:
|
||||
# Special case to handle serial port change for blackbox
|
||||
if data_device['type'] == 'fiscal_data_module' and 'BODO001' in data_device['name']:
|
||||
existing_blackbox = connected_iot_devices.search([
|
||||
('iot_id', '=', box.id), ('name', 'like', 'BODO001'), ('type', '=', 'fiscal_data_module')
|
||||
], limit=1)
|
||||
if existing_blackbox:
|
||||
existing_blackbox.write({'identifier': device_identifier})
|
||||
connected_iot_devices |= existing_blackbox
|
||||
continue
|
||||
|
||||
device = connected_iot_devices.search([
|
||||
('iot_id', '=', box.id), ('identifier', '=', device_identifier)
|
||||
])
|
||||
|
||||
# If an `iot.device` record isn't found for this `device`, create a new one.
|
||||
if not device:
|
||||
device = request.env['iot.device'].sudo().create({
|
||||
'iot_id': box.id,
|
||||
'name': data_device['name'],
|
||||
'identifier': device_identifier,
|
||||
'type': data_device['type'],
|
||||
'manufacturer': data_device.get('manufacturer'),
|
||||
'connection': data_device['connection'],
|
||||
'subtype': data_device.get('subtype', ''),
|
||||
})
|
||||
elif device and device.type != data_device.get('type') or (device.subtype == '' and device.type == 'printer'):
|
||||
device.write({
|
||||
'name': data_device.get('name'),
|
||||
'type': data_device.get('type'),
|
||||
'manufacturer': data_device.get('manufacturer'),
|
||||
'subtype': data_device.get('subtype', '')
|
||||
})
|
||||
|
||||
connected_iot_devices |= device
|
||||
# Mark the received devices as connected, disconnect the others.
|
||||
connected_iot_devices.write({'connected_status': 'connected'})
|
||||
(previously_connected_iot_devices - connected_iot_devices).write({'connected_status': 'disconnected'})
|
||||
iot_channel = request.env['iot.channel'].sudo().get_iot_channel()
|
||||
return iot_channel
|
||||
return None
|
||||
|
||||
def _is_iot_log_enabled(self):
|
||||
return str2bool(request.env['ir.config_parameter'].sudo().get_param('iot.should_log_iot_logs', True))
|
||||
|
||||
@http.route('/iot/log', type='http', auth='public', csrf=False)
|
||||
def receive_iot_log(self):
|
||||
IOT_ELEMENT_SEPARATOR = b'<log/>\n'
|
||||
IOT_LOG_LINE_SEPARATOR = b','
|
||||
IOT_IDENTIFIER_PREFIX = b'identifier '
|
||||
|
||||
def log_line_transformation(log_line):
|
||||
split = log_line.split(IOT_LOG_LINE_SEPARATOR, 1)
|
||||
return {'levelno': int(split[0]), 'line_formatted': split[1].decode('utf-8')}
|
||||
|
||||
def log_current_level():
|
||||
_iot_logger.log(
|
||||
log_level,
|
||||
"%s%s",
|
||||
init_log_message,
|
||||
textwrap.indent("\n".join(['', *log_lines]), ' | ')
|
||||
)
|
||||
|
||||
def finish_request():
|
||||
return Response(status=200)
|
||||
|
||||
if not self._is_iot_log_enabled():
|
||||
return finish_request()
|
||||
|
||||
request_data = request.httprequest.get_data()
|
||||
if request_data.endswith(IOT_ELEMENT_SEPARATOR):
|
||||
# Do not use rstrip as some characters of the separator might be at the end of the log line
|
||||
request_data = request_data[:-len(IOT_ELEMENT_SEPARATOR)]
|
||||
request_data_split = request_data.split(IOT_ELEMENT_SEPARATOR)
|
||||
if len(request_data_split) < 2:
|
||||
return finish_request()
|
||||
|
||||
identifier_details = request_data_split.pop(0)
|
||||
if not identifier_details.startswith(IOT_IDENTIFIER_PREFIX):
|
||||
return finish_request()
|
||||
|
||||
identifier = identifier_details[len(IOT_IDENTIFIER_PREFIX):]
|
||||
iot_box = self._search_box(identifier)
|
||||
if not iot_box:
|
||||
return finish_request()
|
||||
|
||||
log_details = map(log_line_transformation, request_data_split)
|
||||
init_log_message = "IoT box log '%s' #%d received:" % (iot_box.name, iot_box.id)
|
||||
|
||||
for log_level, log_group in itertools.groupby(log_details, key=lambda log: log['levelno']): # noqa: B007
|
||||
log_lines = [log_line['line_formatted'] for log_line in log_group]
|
||||
log_current_level()
|
||||
|
||||
return finish_request()
|
||||
|
||||
@http.route('/iot/box/update_certificate_status', type='jsonrpc', auth='public')
|
||||
def update_certificate_status(self, identifier, ssl_certificate_end_date):
|
||||
"""Update the SSL certificate end date for the IoT Box.
|
||||
|
||||
:param str identifier: IoT Box identifier
|
||||
:param str ssl_certificate_end_date: SSL certificate end date
|
||||
"""
|
||||
box = self._search_box(identifier)
|
||||
if not box:
|
||||
_logger.warning("No IoT Box found with identifier '%s'. Request ignored", identifier)
|
||||
return
|
||||
|
||||
box.write({'ssl_certificate_end_date': ssl_certificate_end_date})
|
||||
BIN
fusion_iot/iot/demo/._iot_demo.xml
Normal file
BIN
fusion_iot/iot/demo/._iot_demo.xml
Normal file
Binary file not shown.
125
fusion_iot/iot/demo/iot_demo.xml
Normal file
125
fusion_iot/iot/demo/iot_demo.xml
Normal file
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- IoT Boxes -->
|
||||
|
||||
<record id="iot_box_shop" model="iot.box">
|
||||
<field name="name">Shop</field>
|
||||
<field name="identifier">00:00:00:00:00:00</field>
|
||||
<field name="ip">0.0.0.0</field>
|
||||
<field name="version">L19.12-17.0#3bf1a33</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_box_workshop" model="iot.box">
|
||||
<field name="name">Workshop</field>
|
||||
<field name="identifier">11:11:11:11:11:11</field>
|
||||
<field name="ip">1.1.1.1</field>
|
||||
<field name="version">W19.12</field>
|
||||
</record>
|
||||
|
||||
<!-- IoT Devices -->
|
||||
|
||||
<record id="iot_printer" model="iot.device">
|
||||
<field name="name">Receipt Printer</field>
|
||||
<field name="iot_id" ref="iot_box_shop"/>
|
||||
<field name="identifier">printer_identifier</field>
|
||||
<field name="type">printer</field>
|
||||
<field name="subtype">receipt_printer</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">network</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_scanner" model="iot.device">
|
||||
<field name="name">Barcode Scanner</field>
|
||||
<field name="iot_id" ref="iot_box_shop"/>
|
||||
<field name="identifier">scanner_identifier</field>
|
||||
<field name="type">scanner</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">direct</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_payment" model="iot.device">
|
||||
<field name="name">Payment Terminal</field>
|
||||
<field name="iot_id" ref="iot_box_shop"/>
|
||||
<field name="identifier">payment_identifier</field>
|
||||
<field name="type">payment</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">network</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_scale" model="iot.device">
|
||||
<field name="name">Scale</field>
|
||||
<field name="iot_id" ref="iot_box_shop"/>
|
||||
<field name="identifier">scale_identifier</field>
|
||||
<field name="type">scale</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">serial</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_display" model="iot.device">
|
||||
<field name="name">Customer Display</field>
|
||||
<field name="iot_id" ref="iot_box_shop"/>
|
||||
<field name="identifier">display_identifier</field>
|
||||
<field name="type">display</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">hdmi</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_fdm" model="iot.device">
|
||||
<field name="name">Fiscal Data Module</field>
|
||||
<field name="iot_id" ref="iot_box_shop"/>
|
||||
<field name="identifier">fdm_identifier</field>
|
||||
<field name="type">fiscal_data_module</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">serial</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_keyboard" model="iot.device">
|
||||
<field name="name">USB Keyboard</field>
|
||||
<field name="iot_id" ref="iot_box_workshop"/>
|
||||
<field name="identifier">keyboard_identifier</field>
|
||||
<field name="type">keyboard</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">direct</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_camera" model="iot.device">
|
||||
<field name="name">Camera</field>
|
||||
<field name="iot_id" ref="iot_box_workshop"/>
|
||||
<field name="identifier">camera_identifier</field>
|
||||
<field name="type">camera</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">direct</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_device" model="iot.device">
|
||||
<field name="name">Caliper</field>
|
||||
<field name="iot_id" ref="iot_box_workshop"/>
|
||||
<field name="identifier">device_identifier</field>
|
||||
<field name="type">device</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">bluetooth</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
<record id="iot_unsupported_device" model="iot.device">
|
||||
<field name="name">Unsupported Device</field>
|
||||
<field name="iot_id" ref="iot_box_workshop"/>
|
||||
<field name="identifier">unsupported_identifier</field>
|
||||
<field name="type">unsupported</field>
|
||||
<field name="manufacturer"></field>
|
||||
<field name="connection">serial</field>
|
||||
<field name="connected_status">disconnected</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
BIN
fusion_iot/iot/i18n/._ar.po
Normal file
BIN
fusion_iot/iot/i18n/._ar.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._az.po
Normal file
BIN
fusion_iot/iot/i18n/._az.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._bg.po
Normal file
BIN
fusion_iot/iot/i18n/._bg.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._bs.po
Normal file
BIN
fusion_iot/iot/i18n/._bs.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._ca.po
Normal file
BIN
fusion_iot/iot/i18n/._ca.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._cs.po
Normal file
BIN
fusion_iot/iot/i18n/._cs.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._da.po
Normal file
BIN
fusion_iot/iot/i18n/._da.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._de.po
Normal file
BIN
fusion_iot/iot/i18n/._de.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._el.po
Normal file
BIN
fusion_iot/iot/i18n/._el.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._es.po
Normal file
BIN
fusion_iot/iot/i18n/._es.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._es_419.po
Normal file
BIN
fusion_iot/iot/i18n/._es_419.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._et.po
Normal file
BIN
fusion_iot/iot/i18n/._et.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._fa.po
Normal file
BIN
fusion_iot/iot/i18n/._fa.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._fi.po
Normal file
BIN
fusion_iot/iot/i18n/._fi.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._fr.po
Normal file
BIN
fusion_iot/iot/i18n/._fr.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._gu.po
Normal file
BIN
fusion_iot/iot/i18n/._gu.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._he.po
Normal file
BIN
fusion_iot/iot/i18n/._he.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._hi.po
Normal file
BIN
fusion_iot/iot/i18n/._hi.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._hr.po
Normal file
BIN
fusion_iot/iot/i18n/._hr.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._hu.po
Normal file
BIN
fusion_iot/iot/i18n/._hu.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._id.po
Normal file
BIN
fusion_iot/iot/i18n/._id.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._iot.pot
Normal file
BIN
fusion_iot/iot/i18n/._iot.pot
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._is.po
Normal file
BIN
fusion_iot/iot/i18n/._is.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._it.po
Normal file
BIN
fusion_iot/iot/i18n/._it.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._ja.po
Normal file
BIN
fusion_iot/iot/i18n/._ja.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._kab.po
Normal file
BIN
fusion_iot/iot/i18n/._kab.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._km.po
Normal file
BIN
fusion_iot/iot/i18n/._km.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._ko.po
Normal file
BIN
fusion_iot/iot/i18n/._ko.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._ku.po
Normal file
BIN
fusion_iot/iot/i18n/._ku.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._lb.po
Normal file
BIN
fusion_iot/iot/i18n/._lb.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._lt.po
Normal file
BIN
fusion_iot/iot/i18n/._lt.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._lv.po
Normal file
BIN
fusion_iot/iot/i18n/._lv.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._mn.po
Normal file
BIN
fusion_iot/iot/i18n/._mn.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._my.po
Normal file
BIN
fusion_iot/iot/i18n/._my.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._nb.po
Normal file
BIN
fusion_iot/iot/i18n/._nb.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._nl.po
Normal file
BIN
fusion_iot/iot/i18n/._nl.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._pl.po
Normal file
BIN
fusion_iot/iot/i18n/._pl.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._pt.po
Normal file
BIN
fusion_iot/iot/i18n/._pt.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._pt_BR.po
Normal file
BIN
fusion_iot/iot/i18n/._pt_BR.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._ro.po
Normal file
BIN
fusion_iot/iot/i18n/._ro.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._ru.po
Normal file
BIN
fusion_iot/iot/i18n/._ru.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._sk.po
Normal file
BIN
fusion_iot/iot/i18n/._sk.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._sl.po
Normal file
BIN
fusion_iot/iot/i18n/._sl.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._sr@latin.po
Normal file
BIN
fusion_iot/iot/i18n/._sr@latin.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._sv.po
Normal file
BIN
fusion_iot/iot/i18n/._sv.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._th.po
Normal file
BIN
fusion_iot/iot/i18n/._th.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._tr.po
Normal file
BIN
fusion_iot/iot/i18n/._tr.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._uk.po
Normal file
BIN
fusion_iot/iot/i18n/._uk.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._vi.po
Normal file
BIN
fusion_iot/iot/i18n/._vi.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._zh_CN.po
Normal file
BIN
fusion_iot/iot/i18n/._zh_CN.po
Normal file
Binary file not shown.
BIN
fusion_iot/iot/i18n/._zh_TW.po
Normal file
BIN
fusion_iot/iot/i18n/._zh_TW.po
Normal file
Binary file not shown.
1475
fusion_iot/iot/i18n/ar.po
Normal file
1475
fusion_iot/iot/i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
1276
fusion_iot/iot/i18n/az.po
Normal file
1276
fusion_iot/iot/i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
1267
fusion_iot/iot/i18n/bg.po
Normal file
1267
fusion_iot/iot/i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
1278
fusion_iot/iot/i18n/bs.po
Normal file
1278
fusion_iot/iot/i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
1466
fusion_iot/iot/i18n/ca.po
Normal file
1466
fusion_iot/iot/i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
1365
fusion_iot/iot/i18n/cs.po
Normal file
1365
fusion_iot/iot/i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
1424
fusion_iot/iot/i18n/da.po
Normal file
1424
fusion_iot/iot/i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
1524
fusion_iot/iot/i18n/de.po
Normal file
1524
fusion_iot/iot/i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
1282
fusion_iot/iot/i18n/el.po
Normal file
1282
fusion_iot/iot/i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
1491
fusion_iot/iot/i18n/es.po
Normal file
1491
fusion_iot/iot/i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
1376
fusion_iot/iot/i18n/es_419.po
Normal file
1376
fusion_iot/iot/i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
1391
fusion_iot/iot/i18n/et.po
Normal file
1391
fusion_iot/iot/i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
1290
fusion_iot/iot/i18n/fa.po
Normal file
1290
fusion_iot/iot/i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
1493
fusion_iot/iot/i18n/fi.po
Normal file
1493
fusion_iot/iot/i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user