Compare commits
16 Commits
fusion_acc
...
e14ad21689
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e14ad21689 | ||
|
|
0a9ed635e8 | ||
|
|
a93162cb70 | ||
|
|
a90a349fbc | ||
|
|
6d90789967 | ||
|
|
5c3e7a3cf3 | ||
|
|
e01a2a0e35 | ||
|
|
8fc864623b | ||
|
|
11837ed4f5 | ||
|
|
050d3d06a7 | ||
|
|
41336b179f | ||
|
|
f979bc686d | ||
|
|
7fa54d8fc9 | ||
|
|
c7ecd90982 | ||
|
|
2804168d9e | ||
|
|
6e964c230f |
167
fusion_accounting/PHASE_2_PLAN.md
Normal file
167
fusion_accounting/PHASE_2_PLAN.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Phase 2 — Fusion Accounting Reports Implementation Plan
|
||||||
|
|
||||||
|
**Module:** `fusion_accounting_reports`
|
||||||
|
**Branch:** `fusion_accounting/phase-2-reports`
|
||||||
|
**Pre-phase tag:** `fusion_accounting/pre-phase-2`
|
||||||
|
**Estimated tasks:** 46
|
||||||
|
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace Odoo Enterprise's `account_reports` module with a Fusion-native financial reports engine. CORE scope: P&L (income statement), balance sheet, trial balance, general ledger with drill-down. AI augmentation: anomaly detection (variance vs prior period) + AI-generated commentary. Coexists with Enterprise (Enterprise wins by default; Fusion menu shows when Enterprise absent).
|
||||||
|
|
||||||
|
## Architecture (HYBRID engine)
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion.report.engine (AbstractModel) ← shared primitives
|
||||||
|
├── compute_pnl(period, comparison=None)
|
||||||
|
├── compute_balance_sheet(date_to, comparison=None)
|
||||||
|
├── compute_trial_balance(period)
|
||||||
|
├── compute_gl(period, account_ids=None)
|
||||||
|
├── drill_down(report_type, line_id, period)
|
||||||
|
└── _walk_account_hierarchy(root_account_ids)
|
||||||
|
|
||||||
|
services/ ← pure-Python
|
||||||
|
├── date_periods.py → fiscal-period math, comparison-period derivation
|
||||||
|
├── account_hierarchy.py → recursive account tree walk + roll-ups
|
||||||
|
├── totaling.py → balance/credit/debit aggregation rules
|
||||||
|
├── currency_conversion.py → multi-currency revaluation at report date
|
||||||
|
├── anomaly_detection.py → variance vs prior-period statistical flags
|
||||||
|
└── commentary_generator.py → LLM prompt + parse for narrative
|
||||||
|
|
||||||
|
models/
|
||||||
|
├── fusion_report.py → report definition (metadata, line specs)
|
||||||
|
├── fusion_report_engine.py → AbstractModel orchestrator
|
||||||
|
├── fusion_report_pnl.py → P&L definition + execute
|
||||||
|
├── fusion_report_balance_sheet.py
|
||||||
|
├── fusion_report_trial_balance.py
|
||||||
|
├── fusion_report_general_ledger.py
|
||||||
|
├── fusion_report_anomaly.py → persisted flagged variances
|
||||||
|
├── fusion_report_commentary.py → cached AI narratives
|
||||||
|
└── fusion_unreconciled_gl_mv.py → MV for fast GL listing on large DBs
|
||||||
|
|
||||||
|
controllers/bank_rec_controller.py ← 8 JSON-RPC endpoints
|
||||||
|
├── /fusion/reports/run → execute one report
|
||||||
|
├── /fusion/reports/drill_down → drill into a report line
|
||||||
|
├── /fusion/reports/get_anomalies → list flagged variances
|
||||||
|
├── /fusion/reports/get_commentary → fetch / regenerate narrative
|
||||||
|
├── /fusion/reports/compare_periods → side-by-side comparison
|
||||||
|
├── /fusion/reports/export_pdf → PDF export
|
||||||
|
├── /fusion/reports/export_xlsx → XLSX export
|
||||||
|
└── /fusion/reports/list_available → list all report types
|
||||||
|
|
||||||
|
static/src/
|
||||||
|
├── scss/ ← report-specific design tokens
|
||||||
|
├── services/reports_service.js ← reactive state + RPC wrappers
|
||||||
|
├── views/reports_viewer/ ← top-level OWL controller
|
||||||
|
└── components/ ← report_table, drill_down_dialog,
|
||||||
|
period_filter, ai_commentary_panel,
|
||||||
|
anomaly_strip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coexistence
|
||||||
|
|
||||||
|
Same pattern as Phase 1: `group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Reports menu only visible when `account_reports` is NOT installed. Engine + AI tools always available.
|
||||||
|
|
||||||
|
## Tasks (46 total)
|
||||||
|
|
||||||
|
### Group 1: Foundation (tasks 1-2)
|
||||||
|
1. Safety net (tag pre-phase-2, branch phase-2-reports) — **DONE**
|
||||||
|
2. Plan doc + module skeleton
|
||||||
|
|
||||||
|
### Group 2: Engine primitives — TDD layered (tasks 3-8)
|
||||||
|
3. `services/date_periods.py` (fiscal periods, comparison derivation)
|
||||||
|
4. `services/currency_conversion.py` + `services/account_hierarchy.py` + `services/totaling.py`
|
||||||
|
5. `models/fusion_report.py` (report definition model)
|
||||||
|
6. `services/line_resolver.py` (compute report rows from definition)
|
||||||
|
7. `services/drill_down_resolver.py`
|
||||||
|
8. `models/fusion_report_engine.py` (5-method API: compute_pnl, compute_balance_sheet, compute_trial_balance, compute_gl, drill_down)
|
||||||
|
|
||||||
|
### Group 3: Per-report models (tasks 9-12)
|
||||||
|
9. P&L (income statement)
|
||||||
|
10. Balance sheet
|
||||||
|
11. Trial balance
|
||||||
|
12. General ledger
|
||||||
|
|
||||||
|
### Group 4: AI features (tasks 13-17)
|
||||||
|
13. Anomaly detection service (variance vs prior period)
|
||||||
|
14. AI commentary service
|
||||||
|
15. Commentary prompt + LLMProvider integration
|
||||||
|
16. `fusion.report.commentary` persisted model
|
||||||
|
17. `fusion.report.anomaly` persisted model
|
||||||
|
|
||||||
|
### Group 5: Backend wiring (tasks 18-20)
|
||||||
|
18. JSON-RPC controller (8 endpoints)
|
||||||
|
19. ReportsAdapter `_via_fusion` paths
|
||||||
|
20. 5 new AI tools
|
||||||
|
|
||||||
|
### Group 6: Tests + perf (tasks 21-25)
|
||||||
|
21. Property-based tests (totals balance invariant)
|
||||||
|
22. Integration tests — P&L correctness vs known fixtures
|
||||||
|
23. Integration tests — balance sheet + trial balance
|
||||||
|
24. Materialized view for GL
|
||||||
|
25. Cron jobs (anomaly scan + commentary refresh)
|
||||||
|
|
||||||
|
### Group 7: Frontend (tasks 26-33)
|
||||||
|
26. SCSS tokens + main report stylesheet
|
||||||
|
27. `reports_service.js`
|
||||||
|
28. `report_viewer` component (top-level)
|
||||||
|
29. `report_table` component (rows, totals, drill chevrons)
|
||||||
|
30. `drill_down_dialog`
|
||||||
|
31. `period_filter` (date range + comparison toggle)
|
||||||
|
32. `ai_commentary_panel` (Fusion-only)
|
||||||
|
33. `anomaly_strip` (Fusion-only)
|
||||||
|
|
||||||
|
### Group 8: Export + wizards (tasks 34-36)
|
||||||
|
34. PDF export (QWeb template per report)
|
||||||
|
35. XLSX export wizard
|
||||||
|
36. Period selection + comparison wizard
|
||||||
|
|
||||||
|
### Group 9: Migration + coexistence (tasks 37-39)
|
||||||
|
37. Migration wizard inheritance (cache existing definitions)
|
||||||
|
38. Menu + window actions with coexistence group filter
|
||||||
|
39. Coexistence test
|
||||||
|
|
||||||
|
### Group 10: Final tests + polish (tasks 40-46)
|
||||||
|
40. 5 OWL tour tests
|
||||||
|
41. Performance benchmarks
|
||||||
|
42. Optimize if benchmarks fail (conditional)
|
||||||
|
43. Local LLM compat test for commentary
|
||||||
|
44. Update meta-module manifest
|
||||||
|
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
|
||||||
|
46. End-to-end smoke + tag phase-2-complete + push
|
||||||
|
|
||||||
|
## Performance Targets (P95)
|
||||||
|
|
||||||
|
- `engine.compute_pnl` (1 year, 500 accounts): <2s
|
||||||
|
- `engine.compute_balance_sheet`: <2s
|
||||||
|
- `engine.compute_trial_balance`: <1s
|
||||||
|
- `engine.compute_gl` (1 month, all accounts): <3s
|
||||||
|
- `engine.drill_down` (1 line): <500ms
|
||||||
|
- Controller `run` endpoint: <2.5s
|
||||||
|
|
||||||
|
## V19 Conventions (from Phase 1 lessons)
|
||||||
|
|
||||||
|
- `models.Constraint` not `_sql_constraints`
|
||||||
|
- No `@api.depends('id')` on stored compute fields
|
||||||
|
- `@route(type='jsonrpc')` not `type='json'`
|
||||||
|
- `ir.cron` has no `numbercall` field
|
||||||
|
- `res.groups.user_ids` not `users`
|
||||||
|
- `ir.ui.menu.group_ids` not `groups_id`
|
||||||
|
- `res.users.all_group_ids` for searches
|
||||||
|
- `models.Constraint` for unique-keys
|
||||||
|
- Prefer `env.flush_all()` before MV REFRESH
|
||||||
|
|
||||||
|
## Test Targets
|
||||||
|
|
||||||
|
Match Phase 1's test pyramid:
|
||||||
|
- Unit (services pure-Python)
|
||||||
|
- Integration (engine end-to-end with factories)
|
||||||
|
- Property-based (Hypothesis, totals balance invariant)
|
||||||
|
- Controller (HttpCase JSON-RPC)
|
||||||
|
- MV correctness
|
||||||
|
- Performance benchmarks (tagged 'benchmark')
|
||||||
|
- OWL tours (tagged 'tour')
|
||||||
|
- Local LLM smoke (tagged 'local_llm', skips when no LLM)
|
||||||
|
|
||||||
|
Phase 1 final: 157 tests passing. Phase 2 target: ~120-150 additional.
|
||||||
1
fusion_accounting_reports/__init__.py
Normal file
1
fusion_accounting_reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import services
|
||||||
43
fusion_accounting_reports/__manifest__.py
Normal file
43
fusion_accounting_reports/__manifest__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
'name': 'Fusion Accounting Reports',
|
||||||
|
'version': '19.0.1.0.0',
|
||||||
|
'category': 'Accounting/Accounting',
|
||||||
|
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||||
|
'description': """
|
||||||
|
Fusion Accounting Reports
|
||||||
|
=========================
|
||||||
|
|
||||||
|
A Fusion-native replacement for Odoo Enterprise's account_reports module.
|
||||||
|
|
||||||
|
CORE scope (Phase 2):
|
||||||
|
- Income Statement (P&L)
|
||||||
|
- Balance Sheet
|
||||||
|
- Trial Balance
|
||||||
|
- General Ledger (with drill-down)
|
||||||
|
|
||||||
|
AI augmentation:
|
||||||
|
- Anomaly detection (variance vs prior period)
|
||||||
|
- AI commentary (LLM-generated narrative)
|
||||||
|
|
||||||
|
Coexists with Enterprise: when account_reports is installed, the Fusion
|
||||||
|
menu hides; the engine and AI tools remain available for the chat.
|
||||||
|
""",
|
||||||
|
'author': 'Fusion Accounting',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': [
|
||||||
|
'fusion_accounting_core',
|
||||||
|
'fusion_accounting_ai',
|
||||||
|
'account',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'application': False,
|
||||||
|
'icon': '/fusion_accounting_reports/static/description/icon.png',
|
||||||
|
}
|
||||||
0
fusion_accounting_reports/controllers/__init__.py
Normal file
0
fusion_accounting_reports/controllers/__init__.py
Normal file
0
fusion_accounting_reports/models/__init__.py
Normal file
0
fusion_accounting_reports/models/__init__.py
Normal file
0
fusion_accounting_reports/reports/__init__.py
Normal file
0
fusion_accounting_reports/reports/__init__.py
Normal file
1
fusion_accounting_reports/security/ir.model.access.csv
Normal file
1
fusion_accounting_reports/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
4
fusion_accounting_reports/services/__init__.py
Normal file
4
fusion_accounting_reports/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import date_periods
|
||||||
|
from . import account_hierarchy
|
||||||
|
from . import totaling
|
||||||
|
from . import currency_conversion
|
||||||
62
fusion_accounting_reports/services/account_hierarchy.py
Normal file
62
fusion_accounting_reports/services/account_hierarchy.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Account hierarchy walker.
|
||||||
|
|
||||||
|
Given a flat list of accounts with parent_id pointers, build a tree and
|
||||||
|
provide a recursive walker that yields (account, depth, ancestors) tuples.
|
||||||
|
Used by report line resolvers to render group sub-totals."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountNode:
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
account_type: str
|
||||||
|
parent_id: int | None
|
||||||
|
children: list['AccountNode'] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree(accounts: list[dict]) -> list[AccountNode]:
|
||||||
|
"""Build a forest from a flat list of account dicts.
|
||||||
|
|
||||||
|
Each dict must have keys: id, code, name, account_type, parent_id (nullable)."""
|
||||||
|
nodes: dict[int, AccountNode] = {}
|
||||||
|
for acc in accounts:
|
||||||
|
nodes[acc['id']] = AccountNode(
|
||||||
|
id=acc['id'], code=acc['code'], name=acc['name'],
|
||||||
|
account_type=acc['account_type'],
|
||||||
|
parent_id=acc.get('parent_id'),
|
||||||
|
)
|
||||||
|
roots: list[AccountNode] = []
|
||||||
|
for node in nodes.values():
|
||||||
|
if node.parent_id and node.parent_id in nodes:
|
||||||
|
nodes[node.parent_id].children.append(node)
|
||||||
|
else:
|
||||||
|
roots.append(node)
|
||||||
|
for node in nodes.values():
|
||||||
|
node.children.sort(key=lambda n: n.code)
|
||||||
|
roots.sort(key=lambda n: n.code)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def walk(roots: list[AccountNode], *, max_depth: int = 10) -> Iterator[tuple[AccountNode, int, list[AccountNode]]]:
|
||||||
|
"""Depth-first walk yielding (node, depth, ancestors)."""
|
||||||
|
def _walk(node: AccountNode, depth: int, ancestors: list[AccountNode]):
|
||||||
|
yield (node, depth, ancestors)
|
||||||
|
if depth < max_depth:
|
||||||
|
for child in node.children:
|
||||||
|
yield from _walk(child, depth + 1, ancestors + [node])
|
||||||
|
for root in roots:
|
||||||
|
yield from _walk(root, 0, [])
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_account_type(roots: list[AccountNode], type_prefix: str) -> list[AccountNode]:
|
||||||
|
"""Return all nodes whose account_type starts with type_prefix
|
||||||
|
(e.g. 'asset_' returns asset_receivable, asset_cash, etc.)."""
|
||||||
|
matches: list[AccountNode] = []
|
||||||
|
for node, _depth, _ancestors in walk(roots):
|
||||||
|
if node.account_type.startswith(type_prefix):
|
||||||
|
matches.append(node)
|
||||||
|
return matches
|
||||||
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Multi-currency conversion for financial reports.
|
||||||
|
|
||||||
|
Converts move-line amounts to the report's display currency at the
|
||||||
|
report end-date. Pure-Python - caller provides exchange rates as a
|
||||||
|
dict {(source_code, target_code, date): rate}."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversionRate:
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
rate: float
|
||||||
|
rate_date: date
|
||||||
|
|
||||||
|
|
||||||
|
def convert_amount(amount: float, *, source_currency: str, target_currency: str,
|
||||||
|
rate_date: date, rates: dict) -> float:
|
||||||
|
"""Convert `amount` from source to target at the given date.
|
||||||
|
|
||||||
|
`rates` is a dict keyed by (source, target, date) -> rate.
|
||||||
|
If source == target, returns amount unchanged."""
|
||||||
|
if source_currency == target_currency:
|
||||||
|
return amount
|
||||||
|
key = (source_currency, target_currency, rate_date)
|
||||||
|
if key in rates:
|
||||||
|
return amount * rates[key]
|
||||||
|
inv_key = (target_currency, source_currency, rate_date)
|
||||||
|
if inv_key in rates:
|
||||||
|
inv = rates[inv_key]
|
||||||
|
if inv != 0:
|
||||||
|
return amount / inv
|
||||||
|
candidates = [
|
||||||
|
(d, r) for (s, t, d), r in rates.items()
|
||||||
|
if s == source_currency and t == target_currency and d <= rate_date
|
||||||
|
]
|
||||||
|
if candidates:
|
||||||
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
return amount * candidates[0][1]
|
||||||
|
raise ValueError(
|
||||||
|
f"No exchange rate available for {source_currency}->{target_currency} on or before {rate_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_rates(env, *, target_currency_id: int, as_of: date,
|
||||||
|
source_currency_ids: list[int] | None = None) -> dict:
|
||||||
|
"""Fetch all relevant rates from res.currency.rate as of a given date.
|
||||||
|
|
||||||
|
Returns the dict-of-rates structure consumed by convert_amount.
|
||||||
|
Pulls only rates where source != target and date <= as_of."""
|
||||||
|
Rate = env['res.currency.rate'].sudo()
|
||||||
|
target = env['res.currency'].browse(target_currency_id)
|
||||||
|
domain = [
|
||||||
|
('name', '<=', as_of),
|
||||||
|
('currency_id', '!=', target.id),
|
||||||
|
]
|
||||||
|
if source_currency_ids:
|
||||||
|
domain.append(('currency_id', 'in', source_currency_ids))
|
||||||
|
rates_recs = Rate.search(domain)
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for r in rates_recs:
|
||||||
|
out[(r.currency_id.name, target.name, r.name)] = (1.0 / r.rate) if r.rate else 0.0
|
||||||
|
return out
|
||||||
103
fusion_accounting_reports/services/date_periods.py
Normal file
103
fusion_accounting_reports/services/date_periods.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Date period math for financial reports.
|
||||||
|
|
||||||
|
Pure-Python helpers that compute:
|
||||||
|
- Fiscal year start/end given any reference date + company fiscal year settings
|
||||||
|
- Comparison periods (prior year same period, prior period, etc.)
|
||||||
|
- Period boundaries for monthly / quarterly / yearly reporting
|
||||||
|
|
||||||
|
NO Odoo imports - all callers pass in primitive types so the same module
|
||||||
|
is unit-testable without an Odoo registry."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
PeriodGranularity = Literal['month', 'quarter', 'year', 'custom']
|
||||||
|
ComparisonMode = Literal['none', 'previous_period', 'previous_year']
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Period:
|
||||||
|
date_from: date
|
||||||
|
date_to: date
|
||||||
|
label: str
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.date_from > self.date_to:
|
||||||
|
raise ValueError(f"date_from ({self.date_from}) > date_to ({self.date_to})")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days(self) -> int:
|
||||||
|
return (self.date_to - self.date_from).days + 1
|
||||||
|
|
||||||
|
|
||||||
|
def fiscal_year_bounds(reference_date: date, *, fy_start_month: int = 1,
|
||||||
|
fy_start_day: int = 1) -> Period:
|
||||||
|
"""Return the fiscal year period containing `reference_date`.
|
||||||
|
|
||||||
|
Default: calendar year (Jan 1 - Dec 31). Pass fy_start_month=4, fy_start_day=1
|
||||||
|
for an April-March fiscal year."""
|
||||||
|
if reference_date.month < fy_start_month or (
|
||||||
|
reference_date.month == fy_start_month and reference_date.day < fy_start_day
|
||||||
|
):
|
||||||
|
start_year = reference_date.year - 1
|
||||||
|
else:
|
||||||
|
start_year = reference_date.year
|
||||||
|
start = date(start_year, fy_start_month, fy_start_day)
|
||||||
|
next_start = date(start_year + 1, fy_start_month, fy_start_day)
|
||||||
|
end = next_start - timedelta(days=1)
|
||||||
|
return Period(date_from=start, date_to=end, label=f"FY {start_year}")
|
||||||
|
|
||||||
|
|
||||||
|
def month_bounds(reference_date: date) -> Period:
|
||||||
|
"""Return the calendar month containing `reference_date`."""
|
||||||
|
start = reference_date.replace(day=1)
|
||||||
|
if reference_date.month == 12:
|
||||||
|
next_start = date(reference_date.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
next_start = date(reference_date.year, reference_date.month + 1, 1)
|
||||||
|
return Period(
|
||||||
|
date_from=start,
|
||||||
|
date_to=next_start - timedelta(days=1),
|
||||||
|
label=start.strftime('%B %Y'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def quarter_bounds(reference_date: date) -> Period:
|
||||||
|
"""Return the calendar quarter containing `reference_date`."""
|
||||||
|
quarter = (reference_date.month - 1) // 3 + 1
|
||||||
|
start_month = (quarter - 1) * 3 + 1
|
||||||
|
start = date(reference_date.year, start_month, 1)
|
||||||
|
end_month = start_month + 2
|
||||||
|
if end_month == 12:
|
||||||
|
end = date(reference_date.year, 12, 31)
|
||||||
|
else:
|
||||||
|
end = date(reference_date.year, end_month + 1, 1) - timedelta(days=1)
|
||||||
|
return Period(date_from=start, date_to=end, label=f"Q{quarter} {reference_date.year}")
|
||||||
|
|
||||||
|
|
||||||
|
def comparison_period(period: Period, mode: ComparisonMode) -> Period | None:
|
||||||
|
"""Derive the comparison period for `period` per `mode`.
|
||||||
|
|
||||||
|
`previous_period`: same length, immediately before
|
||||||
|
`previous_year`: same calendar dates, one year earlier
|
||||||
|
`none`: returns None"""
|
||||||
|
if mode == 'none':
|
||||||
|
return None
|
||||||
|
if mode == 'previous_period':
|
||||||
|
days = period.days
|
||||||
|
new_to = period.date_from - timedelta(days=1)
|
||||||
|
new_from = new_to - timedelta(days=days - 1)
|
||||||
|
return Period(date_from=new_from, date_to=new_to,
|
||||||
|
label=f"{period.label} (previous)")
|
||||||
|
if mode == 'previous_year':
|
||||||
|
try:
|
||||||
|
new_from = period.date_from.replace(year=period.date_from.year - 1)
|
||||||
|
new_to = period.date_to.replace(year=period.date_to.year - 1)
|
||||||
|
except ValueError:
|
||||||
|
new_from = period.date_from.replace(year=period.date_from.year - 1, day=28)
|
||||||
|
new_to = period.date_to.replace(year=period.date_to.year - 1, day=28)
|
||||||
|
return Period(date_from=new_from, date_to=new_to,
|
||||||
|
label=f"{period.label} (prev year)")
|
||||||
|
raise ValueError(f"Unknown comparison mode: {mode}")
|
||||||
49
fusion_accounting_reports/services/totaling.py
Normal file
49
fusion_accounting_reports/services/totaling.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Move-line aggregation primitives for report totaling.
|
||||||
|
|
||||||
|
Pure-Python helpers - callers pass dicts with debit/credit/balance/currency keys,
|
||||||
|
no Odoo recordsets needed. Keeps the math testable without an ORM."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TotalLine:
|
||||||
|
debit: float = 0.0
|
||||||
|
credit: float = 0.0
|
||||||
|
balance: float = 0.0
|
||||||
|
debit_currency: float = 0.0
|
||||||
|
credit_currency: float = 0.0
|
||||||
|
balance_currency: float = 0.0
|
||||||
|
line_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(move_lines: list[dict]) -> TotalLine:
|
||||||
|
"""Aggregate a list of move-line dicts into a TotalLine.
|
||||||
|
|
||||||
|
Each dict must have: debit, credit, balance (signed). Optional:
|
||||||
|
debit_currency, credit_currency, balance_currency."""
|
||||||
|
out = TotalLine()
|
||||||
|
for ml in move_lines:
|
||||||
|
out.debit += ml.get('debit', 0.0)
|
||||||
|
out.credit += ml.get('credit', 0.0)
|
||||||
|
out.balance += ml.get('balance', 0.0)
|
||||||
|
out.debit_currency += ml.get('debit_currency', 0.0)
|
||||||
|
out.credit_currency += ml.get('credit_currency', 0.0)
|
||||||
|
out.balance_currency += ml.get('balance_currency', 0.0)
|
||||||
|
out.line_count += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_per_account(move_lines: list[dict]) -> dict[int, TotalLine]:
|
||||||
|
"""Group + aggregate by account_id. Returns {account_id: TotalLine}."""
|
||||||
|
grouped: dict[int, list[dict]] = {}
|
||||||
|
for ml in move_lines:
|
||||||
|
acct = ml['account_id']
|
||||||
|
grouped.setdefault(acct, []).append(ml)
|
||||||
|
return {acct: aggregate(lines) for acct, lines in grouped.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def is_balanced(move_lines: list[dict], *, tolerance: float = 0.005) -> bool:
|
||||||
|
"""True if total debits == total credits (within tolerance for rounding)."""
|
||||||
|
agg = aggregate(move_lines)
|
||||||
|
return abs(agg.debit - agg.credit) <= tolerance
|
||||||
BIN
fusion_accounting_reports/static/description/icon.png
Normal file
BIN
fusion_accounting_reports/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
2
fusion_accounting_reports/tests/__init__.py
Normal file
2
fusion_accounting_reports/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import test_services_unit
|
||||||
|
from . import test_currency_conversion
|
||||||
53
fusion_accounting_reports/tests/test_currency_conversion.py
Normal file
53
fusion_accounting_reports/tests/test_currency_conversion.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Unit tests for currency_conversion service."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.currency_conversion import (
|
||||||
|
convert_amount, fetch_rates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestCurrencyConversion(TransactionCase):
|
||||||
|
|
||||||
|
def test_same_currency_returns_unchanged(self):
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='USD',
|
||||||
|
rate_date=date(2026, 4, 19), rates={})
|
||||||
|
self.assertEqual(result, 100)
|
||||||
|
|
||||||
|
def test_direct_rate(self):
|
||||||
|
rates = {('USD', 'CAD', date(2026, 4, 19)): 1.35}
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates=rates)
|
||||||
|
self.assertEqual(result, 135)
|
||||||
|
|
||||||
|
def test_inverse_rate(self):
|
||||||
|
rates = {('CAD', 'USD', date(2026, 4, 19)): 0.74}
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates=rates)
|
||||||
|
self.assertAlmostEqual(result, 100 / 0.74, places=2)
|
||||||
|
|
||||||
|
def test_falls_back_to_most_recent_rate(self):
|
||||||
|
rates = {
|
||||||
|
('USD', 'CAD', date(2026, 1, 1)): 1.30,
|
||||||
|
('USD', 'CAD', date(2026, 3, 1)): 1.32,
|
||||||
|
}
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates=rates)
|
||||||
|
self.assertEqual(result, 132)
|
||||||
|
|
||||||
|
def test_raises_when_no_rate(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
convert_amount(100, source_currency='EUR',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates={})
|
||||||
|
|
||||||
|
def test_fetch_rates_from_env(self):
|
||||||
|
cad = self.env.ref('base.CAD')
|
||||||
|
rates = fetch_rates(self.env, target_currency_id=cad.id, as_of=date(2026, 4, 19))
|
||||||
|
self.assertIsInstance(rates, dict)
|
||||||
142
fusion_accounting_reports/tests/test_services_unit.py
Normal file
142
fusion_accounting_reports/tests/test_services_unit.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""Unit tests for date_periods, account_hierarchy, totaling services."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.date_periods import (
|
||||||
|
Period, fiscal_year_bounds, month_bounds, quarter_bounds, comparison_period,
|
||||||
|
)
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.account_hierarchy import (
|
||||||
|
build_tree, walk, filter_by_account_type,
|
||||||
|
)
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.totaling import (
|
||||||
|
aggregate, aggregate_per_account, is_balanced,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestDatePeriods(TransactionCase):
|
||||||
|
|
||||||
|
def test_fiscal_year_calendar_default(self):
|
||||||
|
period = fiscal_year_bounds(date(2026, 6, 15))
|
||||||
|
self.assertEqual(period.date_from, date(2026, 1, 1))
|
||||||
|
self.assertEqual(period.date_to, date(2026, 12, 31))
|
||||||
|
|
||||||
|
def test_fiscal_year_april_start(self):
|
||||||
|
period = fiscal_year_bounds(date(2026, 6, 15), fy_start_month=4)
|
||||||
|
self.assertEqual(period.date_from, date(2026, 4, 1))
|
||||||
|
self.assertEqual(period.date_to, date(2027, 3, 31))
|
||||||
|
|
||||||
|
def test_fiscal_year_before_start_returns_prior(self):
|
||||||
|
period = fiscal_year_bounds(date(2026, 2, 15), fy_start_month=4)
|
||||||
|
self.assertEqual(period.date_from, date(2025, 4, 1))
|
||||||
|
self.assertEqual(period.date_to, date(2026, 3, 31))
|
||||||
|
|
||||||
|
def test_month_bounds(self):
|
||||||
|
period = month_bounds(date(2026, 4, 19))
|
||||||
|
self.assertEqual(period.date_from, date(2026, 4, 1))
|
||||||
|
self.assertEqual(period.date_to, date(2026, 4, 30))
|
||||||
|
|
||||||
|
def test_month_bounds_december(self):
|
||||||
|
period = month_bounds(date(2026, 12, 19))
|
||||||
|
self.assertEqual(period.date_from, date(2026, 12, 1))
|
||||||
|
self.assertEqual(period.date_to, date(2026, 12, 31))
|
||||||
|
|
||||||
|
def test_quarter_bounds_q2(self):
|
||||||
|
period = quarter_bounds(date(2026, 5, 15))
|
||||||
|
self.assertEqual(period.date_from, date(2026, 4, 1))
|
||||||
|
self.assertEqual(period.date_to, date(2026, 6, 30))
|
||||||
|
|
||||||
|
def test_comparison_previous_year(self):
|
||||||
|
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'FY 2026')
|
||||||
|
comp = comparison_period(period, 'previous_year')
|
||||||
|
self.assertEqual(comp.date_from, date(2025, 1, 1))
|
||||||
|
self.assertEqual(comp.date_to, date(2025, 12, 31))
|
||||||
|
|
||||||
|
def test_comparison_previous_period_same_length(self):
|
||||||
|
period = Period(date(2026, 4, 1), date(2026, 4, 30), 'Apr 2026')
|
||||||
|
comp = comparison_period(period, 'previous_period')
|
||||||
|
self.assertEqual(comp.date_to, date(2026, 3, 31))
|
||||||
|
self.assertEqual(comp.days, period.days)
|
||||||
|
|
||||||
|
def test_period_validates_bounds(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Period(date(2026, 12, 31), date(2026, 1, 1), 'invalid')
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestAccountHierarchy(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.flat = [
|
||||||
|
{'id': 1, 'code': '1', 'name': 'Assets', 'account_type': 'asset_root', 'parent_id': None},
|
||||||
|
{'id': 2, 'code': '11', 'name': 'Cash', 'account_type': 'asset_cash', 'parent_id': 1},
|
||||||
|
{'id': 3, 'code': '12', 'name': 'AR', 'account_type': 'asset_receivable', 'parent_id': 1},
|
||||||
|
{'id': 4, 'code': '2', 'name': 'Liabilities', 'account_type': 'liability_root', 'parent_id': None},
|
||||||
|
{'id': 5, 'code': '21', 'name': 'AP', 'account_type': 'liability_payable', 'parent_id': 4},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_build_tree_returns_two_roots(self):
|
||||||
|
roots = build_tree(self.flat)
|
||||||
|
self.assertEqual(len(roots), 2)
|
||||||
|
|
||||||
|
def test_walk_yields_all_nodes(self):
|
||||||
|
roots = build_tree(self.flat)
|
||||||
|
ids = [n.id for n, _, _ in walk(roots)]
|
||||||
|
self.assertEqual(set(ids), {1, 2, 3, 4, 5})
|
||||||
|
|
||||||
|
def test_walk_depth_correct(self):
|
||||||
|
roots = build_tree(self.flat)
|
||||||
|
depths = {n.id: depth for n, depth, _ in walk(roots)}
|
||||||
|
self.assertEqual(depths[1], 0)
|
||||||
|
self.assertEqual(depths[2], 1)
|
||||||
|
self.assertEqual(depths[3], 1)
|
||||||
|
|
||||||
|
def test_filter_by_type_prefix(self):
|
||||||
|
roots = build_tree(self.flat)
|
||||||
|
assets = filter_by_account_type(roots, 'asset_')
|
||||||
|
self.assertEqual(len(assets), 3)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestTotaling(TransactionCase):
|
||||||
|
|
||||||
|
def test_aggregate_empty(self):
|
||||||
|
result = aggregate([])
|
||||||
|
self.assertEqual(result.debit, 0.0)
|
||||||
|
self.assertEqual(result.line_count, 0)
|
||||||
|
|
||||||
|
def test_aggregate_simple(self):
|
||||||
|
lines = [
|
||||||
|
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
|
||||||
|
{'debit': 0, 'credit': 50, 'balance': -50, 'account_id': 1},
|
||||||
|
]
|
||||||
|
result = aggregate(lines)
|
||||||
|
self.assertEqual(result.debit, 100)
|
||||||
|
self.assertEqual(result.credit, 50)
|
||||||
|
self.assertEqual(result.balance, 50)
|
||||||
|
|
||||||
|
def test_aggregate_per_account_groups_correctly(self):
|
||||||
|
lines = [
|
||||||
|
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
|
||||||
|
{'debit': 50, 'credit': 0, 'balance': 50, 'account_id': 1},
|
||||||
|
{'debit': 0, 'credit': 25, 'balance': -25, 'account_id': 2},
|
||||||
|
]
|
||||||
|
result = aggregate_per_account(lines)
|
||||||
|
self.assertEqual(result[1].debit, 150)
|
||||||
|
self.assertEqual(result[2].credit, 25)
|
||||||
|
|
||||||
|
def test_is_balanced_true(self):
|
||||||
|
lines = [
|
||||||
|
{'debit': 100, 'credit': 0, 'balance': 100},
|
||||||
|
{'debit': 0, 'credit': 100, 'balance': -100},
|
||||||
|
]
|
||||||
|
self.assertTrue(is_balanced(lines))
|
||||||
|
|
||||||
|
def test_is_balanced_false(self):
|
||||||
|
lines = [
|
||||||
|
{'debit': 100, 'credit': 0, 'balance': 100},
|
||||||
|
{'debit': 0, 'credit': 50, 'balance': -50},
|
||||||
|
]
|
||||||
|
self.assertFalse(is_balanced(lines))
|
||||||
0
fusion_accounting_reports/wizards/__init__.py
Normal file
0
fusion_accounting_reports/wizards/__init__.py
Normal file
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
|
||||||
|
BIN
fusion_iot/fusion_plating_iot/static/description/icon.png
Normal file
BIN
fusion_iot/fusion_plating_iot/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user