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:
gsinghpal
2026-04-19 10:46:45 -04:00
parent c118b7c6b5
commit 6e964c230f
419 changed files with 76449 additions and 0 deletions

130
fusion_iot/CLAUDE.md Normal file
View 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
```

View 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

View 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,
}

View File

@@ -0,0 +1 @@
from . import fp_iot_ingest

View 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',
)

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import fp_tank_sensor
from . import fp_tank_reading
from . import fusion_plating_tank

View 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,
)

View 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',
}

View 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
)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 fp_tank_sensor_operator fp.tank.sensor operator model_fp_tank_sensor fusion_plating.group_fusion_plating_operator 1 0 0 0
3 fp_tank_sensor_supervisor fp.tank.sensor supervisor model_fp_tank_sensor fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 fp_tank_sensor_manager fp.tank.sensor manager model_fp_tank_sensor fusion_plating.group_fusion_plating_manager 1 1 1 1
5 fp_tank_reading_operator fp.tank.reading operator model_fp_tank_reading fusion_plating.group_fusion_plating_operator 1 0 1 0
6 fp_tank_reading_supervisor fp.tank.reading supervisor model_fp_tank_reading fusion_plating.group_fusion_plating_supervisor 1 0 1 0
7 fp_tank_reading_manager fp.tank.reading manager model_fp_tank_reading fusion_plating.group_fusion_plating_manager 1 1 1 1

View 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 &amp; 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>

View File

@@ -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', '&gt;=', (context_today()).strftime('%Y-%m-%d'))]"/>
<filter name="last_24h" string="Last 24h"
domain="[('reading_at', '&gt;=', (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>

View 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>

View File

@@ -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>

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/._controllers Executable file

Binary file not shown.

BIN
fusion_iot/iot/._demo Executable file

Binary file not shown.

BIN
fusion_iot/iot/._i18n Executable file

Binary file not shown.

BIN
fusion_iot/iot/._iot_handlers Executable file

Binary file not shown.

BIN
fusion_iot/iot/._models Executable file

Binary file not shown.

BIN
fusion_iot/iot/._security Executable file

Binary file not shown.

BIN
fusion_iot/iot/._static Executable file

Binary file not shown.

BIN
fusion_iot/iot/._tests Executable file

Binary file not shown.

BIN
fusion_iot/iot/._views Executable file

Binary file not shown.

BIN
fusion_iot/iot/._wizard Executable file

Binary file not shown.

View 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

View 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/**/*',
],
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main

View 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})

Binary file not shown.

View 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

Binary file not shown.

BIN
fusion_iot/iot/i18n/._az.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._bg.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._bs.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._ca.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._cs.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._da.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._de.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._el.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._es.po Normal file

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/i18n/._et.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._fa.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._fi.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._fr.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._gu.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._he.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._hi.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._hr.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._hu.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._id.po Normal file

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/i18n/._is.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._it.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._ja.po Normal file

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/i18n/._km.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._ko.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._ku.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._lb.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._lt.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._lv.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._mn.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._my.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._nb.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._nl.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._pl.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._pt.po Normal file

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/i18n/._ro.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._ru.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._sk.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._sl.po Normal file

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/i18n/._sv.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._th.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._tr.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._uk.po Normal file

Binary file not shown.

BIN
fusion_iot/iot/i18n/._vi.po Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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