| Availability |
Odoo Online
Odoo.sh
On Premise
|
| Lines of code | 2709 |
| Technical Name |
iotai |
| License | OPL-1 |
| Website | https://linkedin.com/in/okky-permana-sihipo |
| Versions | 18.0 19.0 |
| Availability |
Odoo Online
Odoo.sh
On Premise
|
| Lines of code | 2709 |
| Technical Name |
iotai |
| License | OPL-1 |
| Website | https://linkedin.com/in/okky-permana-sihipo |
| Versions | 18.0 19.0 |
Panduan Lengkap Pembangunan Addon Odoo 18: IoT AI
Panduan langkah-demi-langkah teknis terlengkap untuk membangun sistem otomatisasi pertanian cerdas terintegrasi IoT dan AI lokal pada Odoo 18 Community Edition.
Dashboard Hasil Akhir (OWL Frontend)
Komponen antarmuka pengguna ini dibangun menggunakan Odoo Web Library (OWL) dengan visualisasi modern berbasis dark glassmorphism. Dashboard menyediakan pemantauan sensor real-time dengan grafik sparkline interaktif (Chart.js), kontrol relay aktuator secara instan, pemantauan log logis, serta panel rekomendasi bertenaga AI lokal.
ð¬ Hubungi Kami & Layanan Training
Hubungi kami untuk informasi atau menjadwalkan training
Persiapan Compiler & Paket Sistem Operasi (Build Toolchain)
Untuk menjalankan LLM secara lokal via GGUF, kita mengompilasi llama-cpp-python langsung di
mesin host agar mendapatkan akselerasi instruksi perangkat keras CPU/GPU. Pasang dependensi build
esensial berikut:
# Perbarui daftar paket dan pasang utilitas compiler
sudo apt update
sudo apt install build-essential python3-dev cmake git fuser -y
sudo apt update: Menyinkronkan ulang indeks paket dari repositori sistem operasi untuk memastikan kita mengunduh versi compiler dan library terbaru yang kompatibel dengan kernel Linux host.build-essential: Memasang meta-package compiler C/C++ (GCC, G++, Make, dpkg-dev). Hal ini mutakhir karena parser pustaka GGUF AI lokal (llama.cpp) ditulis menggunakan kode C++ native dan harus di-compile langsung menjadi instruksi biner mesin Linux lokal agar inferensi berjalan dengan performa maksimal.python3-dev: Menyediakan file header C, pustaka link statis/dinamis, dan dependensi sistem untuk membangun ekstensi Python C/C++. Tanpa paket ini,pipakan gagal mengompilasi extension wheel untuk compiler binding Python.cmake: Perkakas otomasi build lintas platform yang bertugas mendeteksi struktur CPU dan membangkitkan Makefile yang optimal untuk instruksi CPU modern (seperti AVX2, AVX512, atau arsitektur ARM Neon).fuser: Utilitas sistem untuk mengidentifikasi proses yang memegang port TCP tertentu. Baris perintahfuser -k 8069/tcpdigunakan secara berulang sebelum test runner dijalankan untuk menghentikan instansi Odoo server aktif secara paksa, mencegah bentrokan port TCP yang dapat membatalkan pengujian otomatis.
Konfigurasi Python Conda Environment
Isolasi environment Python Anda menggunakan Conda untuk menghindari konflik pustaka dengan dependensi bawaan sistem operasi.
# Buat environment baru bernama odoo-18 dengan python 3.10
conda create -n odoo-18 python=3.10 -y
# Aktifkan environment conda tersebut
conda activate odoo-18
# Verifikasi versi python aktif
python --version
conda create -n odoo-18 python=3.10 -y: Perintah ini menginstruksikan Conda untuk mengunduh interpreter Python terisolasi versi 3.10. Versi ini adalah target optimal karena memiliki kestabilan runtime API terbaik untuk Odoo 18 serta kecocokan GCC wheel yang aman untuk pustaka LLM lokal. Parameter-ymeniadakan dialog interaktif konfirmasi persetujuan pemasangan.conda activate odoo-18: Memodifikasi variabel lingkungan shell (seperti$PATHdan$PYTHONPATH) agar shell secara aktif menunjuk interpreter Python di `/home/user/.conda/envs/odoo-18/bin`. Langkah ini mengisolasi seluruh dependensi pustaka python backend sehingga tidak merusak package manager global Linux (APT).python --version: Langkah verifikasi sanitasi guna memastikan shell telah berhasil di-redirect ke virtual environment yang benar sebelum langkah instalasi pip dijalankan.
Instalasi Pustaka Tambahan (llama-cpp-python & requests)
Pasang binding Python untuk AI lokal beserta pustaka requests untuk keperluan active
sensor polling.
# Instalasi llama-cpp-python dengan compiler wheel host
pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
# Pasang requests untuk HTTP polling
pip install requests
pip install llama-cpp-python --extra-index-url...: Perintah ini menargetkan repositori wheel khusus yang telah di-compile sebelumnya untuk komputasi CPU standard. Kompilasi ini memanfaatkan instruksi SIMD (Single Instruction, Multiple Data) CPU untuk memproses operasi aljabar linier tensor AI tanpa memerlukan akselerasi kartu grafis NVIDIA CUDA yang mahal.pip install requests: Memasang library HTTP client Python paling populer. Library ini digunakan oleh backend Odoo untuk mengirim API payload aktif ke mikrokontroler hardware fisik maupun simulator sensor, memproses JSON payload, dan memvalidasi kode respons HTTP secara sinkron.
Inisialisasi Addon Odoo 18 & Manifest File
Deklarasikan aset CSS, JavaScript (OWL), QWeb XML, data kronologi, dan menu di dalam berkas manifest modul Odoo.
# -*- coding: utf-8 -*-
{
'name': 'IoT AI Control Hub',
'version': '18.0.1.0.0',
'category': 'IoT/AI',
'summary': 'AI-driven IoT Automation Controller and Dashboard',
'description': """
IoT and AI integration framework for Odoo 18.
Provides local AI inference using llama-cpp-python (GGUF) and simulated rule engines.
Includes an OWL dashboard with real-time Chart.js telemetry charts.
""",
'depends': ['base', 'web'],
'data': [
'security/ir.model.access.csv',
'data/iotai_cron_data.xml',
'views/iotai_views.xml',
],
'demo': [
'demo/iotai_demo_data.xml',
],
'assets': {
'web.assets_backend': [
'iotai/static/lib/marked/marked.min.js',
'iotai/static/src/css/iot_dashboard.css',
'iotai/static/src/js/iot_dashboard.js',
'iotai/static/src/xml/iot_dashboard.xml',
],
},
'installable': True,
'application': True,
'license': 'OPL-1',
}
'depends': ['base', 'web']: Menentukan modul inti Odoo sebagai dependensi. Modulwebsangat krusial karena menyediakan API aset, kerangka aksi klien (Client Action), JS registry, dan framework OWL backend.'demo': ['demo/iotai_demo_data.xml']: Memisahkan pemuatan data uji coba (seperti sensor pH dan temperatur default) agar data demo tersebut hanya di-load ketika server dijalankan dengan bendera pengujian (testing) atau database demo baru, menghindari masuknya record sampah ke database produksi asli.'assets': { 'web.assets_backend': [...] }: Mengintegrasikan file front-end modul ke dalam kompilator aset backend Odoo. Di sini, berkas JS OWL dashboard didaftarkan agar diproses dan di-minify bersama modul-modul backend bawaan Odoo. File `marked.min.js` disertakan secara lokal agar parser Markdown dapat bekerja luring (offline) tanpa dependensi koneksi internet eksternal CDN.
Pendaftaran Modul & Struktur Folder (__init__.py)
Gunakan berkas inisialisasi Python untuk mendaftarkan folder model dan routing API controller.
# -*- coding: utf-8 -*-
from . import models
from . import controllers
# -*- coding: utf-8 -*-
from . import sensor
from . import actuator
from . import ai_engine
from . import project_config
__init__.py(Root): Titik masuk pertama bagi engine pemuat modul Odoo. Berkas ini bertugas mengekspor foldermodels(skema data database) dancontrollers(route HTTP) agar keduanya dipindai oleh kompiler interpreter Python Odoo saat server di-boot.models/__init__.py: Mengontrol urutan pemuatan model. Python memuat berkas satu demi satu. Pengurutan model dependen sepertisensordanactuatordiletakkan di bagian atas agar model konfigurasi globalproject_configyang merujuk relasi (Relation Fields) ke kedua tabel tersebut dapat di-compile dengan sukses tanpa memicu error `KeyError` relasi model.
Desain Model Database IoT (models/sensor.py, models/actuator.py)
Tulis struktur model sensor dan aktuator menggunakan Odoo ORM. Pada model-model ini, kita
mengimplementasikan method init() khusus untuk membersihkan data konflik non-XML sebelum
instalasi guna menghindari UniqueViolation.
# -*- coding: utf-8 -*-
from odoo import models, fields, api
import uuid
class IoTAISensor(models.Model):
_name = "iotai.sensor"
_description = "IoT AI Sensor"
key = fields.Char(string="Key", required=True, index=True)
name = fields.Char(string="Name", required=True)
sensor_type = fields.Selection([
('single', 'Single Value'),
('range', 'Range Value')
], string="Sensor Type", default='single', required=True)
current_value = fields.Char(string="Current Value")
read_mode = fields.Selection([
('active', 'Active Polling'),
('passive', 'Passive Webhook')
], string="Read Mode", default='active', required=True)
webhook_token = fields.Char(string="Webhook Token", default=lambda self: self._generate_token())
polling_url = fields.Char(string="Polling URL")
polling_method = fields.Selection([('GET', 'GET'), ('POST', 'POST')], string="Polling Method", default='GET')
polling_interval = fields.Integer(string="Polling Interval (Seconds)", default=30)
polling_headers = fields.Text(string="Polling Headers (JSON)", default="{}")
polling_payload = fields.Text(string="Polling Payload (JSON)", default="{}")
json_path = fields.Char(string="JSON Path")
last_read_time = fields.Datetime(string="Last Read Time")
is_active = fields.Boolean(string="Active", default=True)
log_ids = fields.One2many('iotai.sensor.log', 'sensor_id', string="Telemetry Logs")
_sql_constraints = [('key_uniq', 'unique(key)', 'Sensor key must be unique!')]
def init(self):
super().init()
# Clean up conflicting keys that are not in ir_model_data
self.env.cr.execute("""
DELETE FROM iotai_sensor
WHERE key IN ('ph_level', 'water_temp', 'ec_level', 'ambient_lux')
AND id NOT IN (
SELECT COALESCE(res_id, 0) FROM ir_model_data WHERE model = 'iotai.sensor'
)
""")
def _generate_token(self):
return uuid.uuid4().hex[:16]
class IoTAISensorLog(models.Model):
_name = "iotai.sensor.log"
_description = "IoT AI Sensor Log"
_order = "timestamp desc"
sensor_id = fields.Many2one('iotai.sensor', string="Sensor", required=True, ondelete='cascade')
value = fields.Char(string="Value", required=True)
timestamp = fields.Datetime(string="Timestamp", default=fields.Datetime.now, required=True)
key = fields.Char(..., index=True): Memberikan index B-Tree pada kolom basis data PostgreSQL untuk menjamin performa pencarian sensor telemetri secepat O(1) sewaktu menerima request HTTP eksternal._sql_constraints = [('key_uniq', 'unique(key)', ...)]: Mencegah duplikasi data logis. Kendala tingkat database (PostgreSQL Unique Index) mencegah data inkonsisten jika ada request write konkuren yang mencoba menulis data ganda.def init(self): Metode bawaan Odoo ORM yang dipanggil setelah tabel dibuat/diperbarui. Kita mengeksekusi SQL rawDELETE FROM iotai_sensor WHERE...untuk menyapu record sensor telemetri lama yang kuncinya bertabrakan dengan XML data demo baru. Bagianid NOT IN (SELECT COALESCE(res_id, 0) FROM ir_model_data...)adalah trik arsitektural krusial: ia menjamin record data yang dikelola secara resmi oleh XML Odoo tidak ikut terhapus, sedangkan record sampah sisa seeder Python lama dibersihkan secara tuntas dari tabel.IoTAISensorLog: Dirancang sebagai tabel terpisah untuk menyimpan deret data historis (timeseries data). Atribut_order = "timestamp desc"memastikan data telemetri terbaru selalu berada pada baris teratas, mempercepat query sparkline UI.
# -*- coding: utf-8 -*-
from odoo import models, fields, api
import uuid
class IoTAIActuator(models.Model):
_name = "iotai.actuator"
_description = "IoT AI Actuator"
key = fields.Char(string="Key", required=True, index=True)
name = fields.Char(string="Name", required=True)
description = fields.Text(string="Description")
state = fields.Selection([('ON', 'ON'), ('OFF', 'OFF')], string="State", default='OFF')
possible_states = fields.Char(string="Possible States", default="ON, OFF")
control_mode = fields.Selection([
('active', 'Active (Odoo pushes)'),
('passive', 'Passive (MCU pulls)')
], string="Control Mode", default='active', required=True)
webhook_token = fields.Char(string="Webhook Token", default=lambda self: self._generate_token())
api_url = fields.Char(string="API URL")
api_method = fields.Selection([('GET', 'GET'), ('POST', 'POST')], string="API Method", default='POST')
api_headers = fields.Text(string="API Headers (JSON)", default="{}")
api_payload_template = fields.Text(string="API Payload Template (JSON)", default="{}")
last_action_time = fields.Datetime(string="Last Action Time")
log_ids = fields.One2many('iotai.actuator.log', 'actuator_id', string="Control Logs")
_sql_constraints = [('key_uniq', 'unique(key)', 'Actuator key must be unique!')]
def init(self):
super().init()
# Clean up conflicting keys that are not in ir_model_data
self.env.cr.execute("""
DELETE FROM iotai_actuator
WHERE key IN ('water_pump', 'ph_down_pump', 'nutrient_pump', 'grow_lights')
AND id NOT IN (
SELECT COALESCE(res_id, 0) FROM ir_model_data WHERE model = 'iotai.actuator'
)
""")
def _generate_token(self):
return uuid.uuid4().hex[:16]
class IoTAIActuatorLog(models.Model):
_name = "iotai.actuator.log"
_description = "IoT AI Actuator Log"
_order = "timestamp desc"
actuator_id = fields.Many2one('iotai.actuator', string="Actuator", required=True, ondelete='cascade')
state = fields.Char(string="State", required=True)
changed_by = fields.Selection([('manual', 'Manual'), ('ai', 'AI Engine')], string="Changed By", default='manual')
timestamp = fields.Datetime(string="Timestamp", default=fields.Datetime.now, required=True)
control_mode: Membedakan aktuator bertipe Active (Odoo langsung memanggil API HTTP milik aktuator/relay saat status berubah) dan Passive (Odoo hanya menyimpan status target, lalu mikrolokal NAT memanggil endpoint Odoo secara periodik untuk menarik/pull status saklar terbaru).api_payload_template: Menyimpan template JSON string (contoh:{"state": "{state}"}) yang nantinya diganti secara dinamis oleh Odoo sebelum mengirimkan request HTTP POST ke relay mikrokontroler.init(self): Menjalankan pembersihan database PostgreSQL serupa untuk menghapus aktuator demo non-XML agar proses kompilasi database bersih dari entri konflik.
Konfigurasi Hak Akses Keamanan (ir.model.access.csv)
Konfigurasikan ACL agar model internal dapat diakses oleh grup pengguna Odoo
(base.group_user).
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_iotai_sensor,access.iotai.sensor,model_iotai_sensor,base.group_user,1,1,1,1
access_iotai_sensor_log,access.iotai.sensor.log,model_iotai_sensor_log,base.group_user,1,1,1,1
access_iotai_actuator,access.iotai.actuator,model_iotai_actuator,base.group_user,1,1,1,1
access_iotai_actuator_log,access.iotai.actuator.log,model_iotai_actuator_log,base.group_user,1,1,1,1
access_iotai_project_config,access.iotai.project.config,model_iotai_project_config,base.group_user,1,1,1,1
access_iotai_ai_decision,access.iotai.ai.decision,model_iotai_ai_decision,base.group_user,1,1,1,1
model_id:id: Mereferensikan ID model internal Odoo secara otomatis (contoh:model_iotai_sensoruntuk modeliotai.sensor). Awalanmodel_adalah konvensi penamaan wajib Odoo.group_id:id: Menggunakanbase.group_user(Internal User) sebagai batas minimum hak akses pengguna agar staf internal Odoo dapat mengoperasikan IoT Dashboard.1,1,1,1: Nilai boolean berturut-turut untuk izin Read, Write, Create, dan Delete. Dalam hal ini, semua hak akses diberikan penuh untuk user internal.
Deklarasi Demo Data Native XML & Sparkline Logs
Hindari programmatic seeder berbasis Python untuk mencegah tabrakan data demo. Gunakan mekanisme
deklaratif Odoo XML melalui demo/iotai_demo_data.xml untuk mendaftarkan konfigurasi,
sensor, aktuator, serta pre-seeded logs untuk visualisasi grafik.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Default configuration record -->
<record id="iotai_project_config_default" model="iotai.project.config">
<field name="name">SIHIPO Hydroponic Smart Farm</field>
<field name="description">Sistem otomatisasi pertanian cerdas berbasis IoT dan AI lokal.</field>
<field name="use_simulated_ai">True</field>
<field name="is_ai_active">True</field>
<field name="system_prompt">- IF water_temp IS greater than 28.0 THEN water_pump = ON
- IF water_temp IS less than 22.0 THEN water_pump = OFF
- IF ph_level IS less than 5.5 THEN ph_down_pump = OFF
- IF ph_level IS greater than 6.5 THEN ph_down_pump = ON
- IF ec_level IS less than 1.2 THEN nutrient_pump = ON
- IF ec_level IS greater than 2.0 THEN nutrient_pump = OFF
- IF ambient_lux IS less than 300.0 THEN grow_lights = ON
- IF ambient_lux IS greater than 700.0 THEN grow_lights = OFF</field>
</record>
<!-- Sensors -->
<record id="sensor_ph_level" model="iotai.sensor">
<field name="key">ph_level</field>
<field name="name">pH Level Sensor</field>
<field name="sensor_type">single</field>
<field name="current_value">6.2</field>
<field name="read_mode">passive</field>
<field name="webhook_token">ph_token_123</field>
<field name="is_active">True</field>
</record>
<!-- Seeded Historical Logs for pH Level -->
<record id="log_ph_level_1" model="iotai.sensor.log">
<field name="sensor_id" ref="sensor_ph_level"/>
<field name="value">6.0</field>
</record>
<record id="log_ph_level_2" model="iotai.sensor.log">
<field name="sensor_id" ref="sensor_ph_level"/>
<field name="value">5.9</field>
</record>
<record id="log_ph_level_3" model="iotai.sensor.log">
<field name="sensor_id" ref="sensor_ph_level"/>
<field name="value">6.2</field>
</record>
</data>
</odoo>
noupdate="1": Atribut krusial yang memberi tahu Odoo untuk memuat record data ini HANYA SEKALI saat modul pertama kali dipasang. Jika pengguna memodifikasi parameter konfigurasi atau token webhook lewat UI, pembaruan modul berikutnya tidak akan menimpa (overwrite) perubahan user tersebut.ref="sensor_ph_level": Relasi XML ID Odoo. Menghubungkan log historis secara langsung ke record sensor pH menggunakan kunci relasional eksternal bawaan sistem Odoo, menjamin integritas data referensial.- Mekanisme ini sepenuhnya menggantikan fungsi dynamic Python seeding, sehingga data demo bersifat deklaratif, terdokumentasi dengan baik, dan bersih dari tabrakan kunci database saat pengujian unit test.
Pembuatan Tampilan Backend XML (views/iotai_views.xml)
Definisikan Window Action, Form View, dan Client Action yang mengaitkan tag backend kita ke OWL Frontend.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Config Form View -->
<record id="view_iotai_project_config_form" model="ir.ui.view">
<field name="name">iotai.project.config.form</field>
<field name="model">iotai.project.config</field>
<field name="arch" type="xml">
<form string="IoT AI Configuration">
<sheet>
<group>
<field name="name"/>
<field name="description"/>
</group>
<group string="AI Automation Parameters">
<group>
<field name="use_simulated_ai"/>
<field name="model_path" invisible="use_simulated_ai == True"/>
<field name="is_ai_active" widget="boolean_toggle"/>
<field name="ai_decision_interval"/>
</group>
</group>
<group string="Automation Prompt Rules">
<field name="system_prompt" widget="text" placeholder="Add custom state rules..."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Client Action untuk Custom OWL Dashboard -->
<record id="action_iotai_dashboard" model="ir.actions.client">
<field name="name">IoT AI Control Hub</field>
<field name="tag">iotai.dashboard</field>
</record>
<!-- Menu Utama -->
<menuitem id="menu_iotai_root" name="IoT AI Control" web_icon="iotai,static/description/icon.png" sequence="10"/>
<menuitem id="menu_iotai_dashboard" name="Dashboard" parent="menu_iotai_root" action="action_iotai_dashboard" sequence="1"/>
</odoo>
invisible="use_simulated_ai == True": Kondisi dinamis (Odoo 18 style). Menyembunyikan inputan path file GGUF jika user memilih opsi "Use Simulated AI (Regex)", menyederhanakan antarmuka administrasi.<field name="tag">iotai.dashboard</field>: Kode pengait utama. String tag ini dicari oleh Odoo JS Registry untuk memetakan dan merender komponen OWL `IoTAIDashboard` saat menu dashboard diklik oleh pengguna.web_icon="iotai,static/description/icon.png": Menentukan ikon navigasi modern untuk app switcher Odoo 18 backend.
Penjadwalan Latar Belakang Native (ir.cron & Automated Tasks)
Gunakan penjadwal bawaan Odoo (ir.cron) untuk mengotomatisasi pemanggilan active polling
sensor dan keputusan AI di latar belakang secara terjadwal.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_poll_active_sensors" model="ir.cron">
<field name="name">IoT AI: Poll Active Sensors</field>
<field name="model_id" ref="model_iotai_sensor"/>
<field name="state">code</field>
<field name="code">model.action_poll_active_sensors()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
<record id="ir_cron_trigger_ai_decision" model="ir.cron">
<field name="name">IoT AI: Trigger AI Decision Cycle</field>
<field name="model_id" ref="model_iotai_project_config"/>
<field name="state">code</field>
<field name="code">model.action_trigger_ai_decision()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</odoo>
<field name="state">code</field>: Memberi tahu Odoo bahwa pemicu cron ini akan mengeksekusi blok kode Python terisolasi (sandboxed code).model.action_poll_active_sensors(): Memanggil metode ORM pada model sensor untuk menyapu (loop) semua sensor aktif dan menarik nilainya dari API hardware masing-masing.numbercall = -1: Mengatur cron agar terus berjalan tanpa batas pengulangan (infinite execution).interval_number = 1daninterval_type = minutes: Menetapkan interval polling sensor aktif berjalan setiap 60 detik sekali.
Rule-Based Simulated AI Fallback Engine
Tulis modul pembantu Python di models/ai_engine.py untuk memproses logika otomasi berbasis
aturan (regex fallback engine) jika server LLM lokal tidak aktif.
# -*- coding: utf-8 -*-
import re
import logging
_logger = logging.getLogger(__name__)
def execute_simulated_inference(system_prompt, sensors, actuators):
"""
Evaluates rule prompt lines. Example format:
- IF water_temp IS greater than 28.0 THEN water_pump = ON
"""
_logger.info("Executing simulated rule-based inference engine")
commands = []
lines = system_prompt.split('\n')
# Map key values
sensor_map = {s['key']: float(s['value']) for s in sensors if s.get('value')}
for line in lines:
if not line.strip().startswith('- IF'):
continue
match = re.match(r'-\s*IF\s+(\w+)\s+(IS\s+)?(greater\s+than|less\s+than|equal\s+to)\s+([\d\.]+)\s+THEN\s+(\w+)\s*=\s*(\w+)', line.strip(), re.IGNORECASE)
if match:
s_key, _, operator, limit_val, a_key, a_state = match.groups()
limit_val = float(limit_val)
if s_key in sensor_map:
current_val = sensor_map[s_key]
triggered = False
if 'greater' in operator.lower() and current_val > limit_val:
triggered = True
elif 'less' in operator.lower() and current_val
sensor_map = {s['key']: float(s['value'])...}: Membuat dictionary cepat (hashmap) untuk memetakan key sensor ke nilainya dalam tipe data float agar evaluasi numerik akurat.re.match(r'- IF ...'): Regex ekspresi reguler yang kuat untuk mem-parsing sintaks pseudo-code natural. Regex menangkap nama sensor, tipe perbandingan (operator), nilai limit batasan, nama aktuator target, dan state keluaran target.- Evaluator ini bertindak sebagai jaring pengaman (fallback engine) yang tangguh. Jika server GGUF/LLM gagal dimuat akibat keterbatasan RAM, sistem akan otomatis beralih ke rule-engine lokal ini untuk menjaga kelangsungan operasional pertanian.
Singleton Manager Model AI GGUF Lokal
Gunakan instansiasi LLM tunggal (singleton) untuk menghemat ruang memori RAM Odoo server saat memuat model AI GGUF secara lokal.
# -*- coding: utf-8 -*-
class LlamaModelManager:
_llm = None
@classmethod
def get_llm(cls, model_path):
"""Loads and caches the Llama GGUF model dynamically."""
if not cls._llm and model_path:
try:
from llama_cpp import Llama
cls._llm = Llama(
model_path=model_path,
n_ctx=2048,
n_threads=4,
verbose=False
)
except Exception as e:
_logger.error("Failed to initialize Llama-CPP-Python: %s", str(e))
cls._llm = None
return cls._llm
def execute_gguf_inference(model_path, system_prompt, prompt):
"""Runs local inference using local GGUF cache manager."""
llm = LlamaModelManager.get_llm(model_path)
if not llm:
return {'status': 'error', 'response': 'Model not loaded'}
full_prompt = f"System: {system_prompt}\nUser: {prompt}\nResponse:"
result = llm(full_prompt, max_tokens=150, stop=["\n", "User:"])
text_res = result['choices'][0]['text'].strip()
return {
'status': 'success',
'response': text_res
}
_llm = None: Variabel privat kelas (class private variable) untuk menyimpan instance Llama CPP yang telah dimuat ke dalam memori RAM RAM.get_llm(cls, model_path): Implementasi pola desain **Singleton Pattern**. Model GGUF yang berukuran gigabyte hanya akan dibaca dari harddisk dan dimuat ke RAM SEKALI saja saat pemanggilan pertama. Pemanggilan berikutnya akan memakai cache memori yang sudah aktif, mencegah Odoo kekehabisan RAM.n_threads=4: Menginstruksikan modul AI untuk mengeksekusi inferensi secara paralel memanfaatkan 4 core CPU fisik demi kecepatan respons instruksi.
Membangun Controller Webhook Terproteksi Token
ESP32/MCU dapat mengirim data ke Odoo melalui route HTTP. Kita menggunakan kelas
http.Controller dengan auth="public". Untuk menulis ke database Odoo sebagai
user publik, kita wajib menggunakan .sudo() agar tidak terkena hambatan ACL security.
# -*- coding: utf-8 -*-
from odoo import http, fields
from odoo.http import request
import json
import logging
_logger = logging.getLogger(__name__)
class IoTAIController(http.Controller):
@http.route('/api/sensor/<string:key>', type='http', auth='public', methods=['POST', 'PUT'], csrf=False)
def sensor_webhook(self, key, **kwargs):
# Gunakan sudo() karena dipanggil oleh user publik tanpa login session
sensor = request.env['iotai.sensor'].sudo().search([
('key', '=', key),
('read_mode', '=', 'passive')
], limit=1)
token = request.httprequest.args.get('token')
if not sensor or token != sensor.webhook_token:
return request.make_response(
json.dumps({'error': 'Unauthorized or invalid sensor key'}),
status=401,
headers=[('Content-Type', 'application/json')]
)
val = request.params.get('value') or request.get_json_data().get('value')
if not val:
return request.make_response(
json.dumps({'error': 'Missing value field'}),
status=400,
headers=[('Content-Type', 'application/json')]
)
# Update telemetry data
sensor.write({
'current_value': str(val),
'last_read_time': fields.Datetime.now()
})
request.env['iotai.sensor.log'].sudo().create({
'sensor_id': sensor.id,
'value': str(val),
'timestamp': fields.Datetime.now()
})
_logger.info("Sensor '%s' updated via webhook: %s", key, val)
return request.make_response(
json.dumps({'status': 'success', 'key': key, 'value': val}),
headers=[('Content-Type', 'application/json')]
)
auth='public'&csrf=False: Endpoint dibuat publik agar mikrokontroler (ESP32/Arduino) yang memiliki memori kecil tidak perlu mengelola otentikasi login berbasis cookie Odoo dan dapat mengirim POST request tanpa token CSRF.token = request.httprequest.args.get('token'): Menyeimbangkan keamanan. Meskipun rute bersifat publik, data masuk tetap diverifikasi secara ketat menggunakan query token rahasia yang unik per sensor.request.env[...].sudo(): Pola desain krusial Odoo. Karena request dikirim tanpa autentikasi user backend Odoo, pemanggilan database wajib menggunakan konteks superuser (sudo) agar tidak memicu error pelanggaran hak akses ACL (Access Control List).
Implementasi Kontrol Status Aktuator Pasif (MCU Pull-Based)
Sediakan rute API GET /api/actuator/<key>/status agar ESP32 yang berada di balik
jaringan NAT/Router lokal tetap dapat menarik instruksi saklar ON/OFF terbaru secara aman.
@http.route('/api/actuator/<string:key>/status', type='http', auth='public', methods=['GET'], csrf=False)
def actuator_status_api(self, key, **kwargs):
actuator = request.env['iotai.actuator'].sudo().search([
('key', '=', key),
('control_mode', '=', 'passive')
], limit=1)
token = request.httprequest.args.get('token')
if not actuator or token != actuator.webhook_token:
return request.make_response(
json.dumps({'error': 'Unauthorized'}),
status=401,
headers=[('Content-Type', 'application/json')]
)
return request.make_response(
json.dumps({
'key': actuator.key,
'state': actuator.state or 'OFF'
}),
headers=[('Content-Type', 'application/json')]
)
- Endpoint ini didesain khusus untuk mengatasi masalah **NAT Traversal**. Banyak mikrokontroler di lapangan tidak memiliki alamat IP publik statis sehingga Odoo tidak bisa memicu HTTP POST langsung ke arah mereka.
- Dengan pola pull-based status ini, mikrokontroler bertindak sebagai inisiator koneksi (client) yang secara periodik mengirimkan GET request ke server Odoo untuk mengetahui apakah status saklarnya (misal: LED Grow Light) diinstruksikan ON atau OFF oleh AI Odoo.
Dashboard OWL Frontend & Integrasi Chart.js
Tulis komponen frontend menggunakan OWL (Odoo Web Library). Kita mengimplementasikan dinamika penting berikut:
- Dynamic Library Loading: Menggunakan
loadBundle("web.chartjs_lib")dalam lifecycleonWillStartuntuk memuat library visualisasi bawaan Odoo secara aman. - HTML Markup Rendering: Mengimpor
markupdari@odoo/owluntuk membungkus hasil parser Markdown (dari librarymarked) sehingga tag HTML dapat di-render secara unescaped oleh directivet-out. - Bypass SCSS Compilation Limit: Odoo mengompilasi dan menggabungkan semua file CSS
di
web.assets_backend. Memasukkan `@import` di dalam CSS modul akan merusak parser aset karena letaknya bergeser ke tengah bundle. Kami mengatasinya dengan memuat Google Fonts secara dinamis menggunakan tag HTML<link>di dalam QWeb XML.
/* @odoo-module */
import { Component, onMounted, onWillStart, onWillUnmount, useState, markup } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { loadBundle } from "@web/core/assets";
export class IoTAIDashboard extends Component {
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
this.state = useState({
project: {
name: "Loading...",
sensors: [],
actuators: [],
ai_decisions: []
},
aiInsightHtml: "",
isGeneratingInsight: false,
isPolling: false
});
this.charts = {};
onWillStart(async () => {
// Memuat Chart.js bawaan Odoo secara dinamis
await loadBundle("web.chartjs_lib");
});
onMounted(() => {
this.loadData();
// Polling data telemetri real-time setiap 3 detik
this.pollInterval = setInterval(() => this.loadData(true), 3000);
});
onWillUnmount(() => {
clearInterval(this.pollInterval);
// Bersihkan instansi Chart.js untuk mencegah kebocoran memori (memory leak) di browser
Object.values(this.charts).forEach(chart => chart.destroy());
});
}
async loadData(isSilent = false) {
try {
const res = await this.orm.call(
"iotai.project.config",
"action_get_realtime_data",
[[]]
);
Object.assign(this.state.project, res);
this.renderCharts();
} catch (error) {
if (!isSilent) {
console.error("Failed to load dashboard data:", error);
this.notification.add("Connection lost to Odoo IoT backend.", { type: "danger" });
}
}
}
async onPollSensors() {
this.state.isPolling = true;
try {
await this.orm.call("iotai.sensor", "action_poll_active_sensors", [[]]);
this.notification.add("Successfully polled active sensors.", { type: "success" });
await this.loadData();
} catch (error) {
this.notification.add("Sensor polling request failed.", { type: "danger" });
} finally {
this.state.isPolling = false;
}
}
async onToggleActuator(key, currentState) {
const targetState = currentState === "ON" ? "OFF" : "ON";
try {
await this.orm.call(
"iotai.actuator",
"action_set_state_by_key",
[[], key, targetState]
);
this.notification.add(`Actuator ${key} set to ${targetState}`, { type: "success" });
await this.loadData();
} catch (error) {
this.notification.add("Failed to update actuator state.", { type: "danger" });
}
}
async onGenerateInsights() {
this.state.isGeneratingInsight = true;
try {
const res = await this.orm.call(
"iotai.project.config",
"action_generate_ai_insight",
[[]]
);
if (res && res.status === "success") {
// Konversi mentah teks Markdown ke HTML lalu dibungkus dengan markup()
this.state.aiInsightHtml = markup(marked.parse(res.insight));
this.notification.add("AI analysis completed.", { type: "success" });
} else {
this.notification.add(res.message || "Failed to parse AI response.", { type: "warning" });
}
} catch (error) {
this.notification.add("Local inference server offline.", { type: "danger" });
} finally {
this.state.isGeneratingInsight = false;
}
}
renderCharts() {
this.state.project.sensors.forEach(sensor => {
const canvasId = `chart_${sensor.key}`;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const logs = sensor.history_logs || [];
const labels = logs.map((_, idx) => idx + 1);
const dataPoints = logs.map(l => parseFloat(l));
if (this.charts[sensor.key]) {
const chart = this.charts[sensor.key];
chart.data.labels = labels;
chart.data.datasets[0].data = dataPoints;
chart.update("none"); // Update grafik secara langsung tanpa efek animasi lambat redundan
} else {
const ctx = canvas.getContext("2d");
this.charts[sensor.key] = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
data: dataPoints,
borderColor: "#00A09D",
backgroundColor: "rgba(0, 160, 157, 0.05)",
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { display: false },
y: { grid: { color: "rgba(255, 255, 255, 0.05)" }, ticks: { color: "#94a3b8" } }
}
}
});
}
});
}
}
IoTAIDashboard.template = "iotai.IoTAIDashboard";
registry.category("actions").add("iotai.dashboard", IoTAIDashboard);
setup(): Konstruktor OWL modern. Di sini kita memanggiluseService("orm")danuseService("notification")untuk menginisialisasi RPC engine bawaan Odoo dan toaster panel info pop-up.useState: Hook reaktivitas OWL. Komponen otomatis merender ulang elemen HTML dashboard jika properti di dalam state (seperti nilai sensor baru atau status saklar relay) mengalami perubahan data.loadBundle("web.chartjs_lib"): Memastikan pustaka Chart.js bawaan sistem Odoo diunduh secara asinkron ke browser hanya saat pengguna mengakses aplikasi IoT. Ini menjaga ukuran aset inti tetap ringan (Web Performance Optimization).markup(marked.parse(...)): Fungsi pengaman esensial OWL. OWL secara bawaan menolak merender variabel yang berisi sintaks HTML (untuk keamanan XSS Injection). Kita memberi tahu OWL secara eksplisit dengan membungkus HTML parser Markdown menggunakan helpermarkup()agar dapat dirender menggunakan tag QWebt-out.chart.update("none"): Menggunakan parameter pembaruan tanpa animasi untuk menghemat kinerja komputasi kartu grafis/CPU pengguna saat telemetri sensor berganti nilai setiap 3 detik.onWillUnmount(): Hook siklus hidup OWL yang mendeteksi saat user berpindah menu navigasi. Di sini kita menghapus timer intervalpollIntervaldan memanggilchart.destroy()pada semua canvas. Langkah ini adalah **best-practice mutlak** untuk mencegah kebocoran memori (JS memory leaks) yang dapat memperlambat browser pengguna.
Odoo Proprietary License v1.0 This software and associated files (the "Software") may only be used (executed, modified, executed after modifications) if you have purchased a valid license from the authors, typically via Odoo Apps, or if you have received a written agreement from the authors of the Software (see the COPYRIGHT file). You may develop Odoo modules that use the Software as a library (typically by depending on it, importing it and using its resources), but without copying any source code or material from the Software. You may distribute those modules under the license of your choice, provided that this license is compatible with the terms of the Odoo Proprietary License (For example: LGPL, MIT, or proprietary licenses similar to this one). It is forbidden to publish, distribute, sublicense, or sell copies of the Software or modified copies of the Software. The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Please log in to comment on this module