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