diff --git a/iot_ux/__init__.py b/iot_ux/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/iot_ux/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/iot_ux/__manifest__.py b/iot_ux/__manifest__.py
new file mode 100644
index 00000000..4d241e66
--- /dev/null
+++ b/iot_ux/__manifest__.py
@@ -0,0 +1,39 @@
+##############################################################################
+#
+# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar)
+# All Rights Reserved.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+{
+ "name": "IoT UX Enhancements",
+ "version": "19.0.1.0.0",
+ "category": "Generic Modules/Base",
+ "author": "ADHOC SA",
+ "license": "AGPL-3",
+ "depends": ["iot"],
+ "data": [
+ "security/ir.model.access.csv",
+ "views/iot_views.xml",
+ "views/ir_actions_report.xml",
+ "views/res_users.xml",
+ ],
+ "assets": {
+ "web.assets_backend": [
+ "iot_ux/static/src/*",
+ ],
+ },
+ "installable": True,
+}
diff --git a/iot_ux/models/__init__.py b/iot_ux/models/__init__.py
new file mode 100644
index 00000000..f57ff3ea
--- /dev/null
+++ b/iot_ux/models/__init__.py
@@ -0,0 +1,3 @@
+from . import ir_actions_report
+from . import res_users
+from . import iot_device
diff --git a/iot_ux/models/iot_device.py b/iot_ux/models/iot_device.py
new file mode 100644
index 00000000..cc3b4125
--- /dev/null
+++ b/iot_ux/models/iot_device.py
@@ -0,0 +1,10 @@
+from odoo import fields, models
+
+
+class iotDevice(models.Model):
+ _inherit = "iot.device"
+
+ iot_report_rule_ids = fields.One2many(
+ "ir.actions.report.iot.rule",
+ "device_id",
+ )
diff --git a/iot_ux/models/ir_actions_report.py b/iot_ux/models/ir_actions_report.py
new file mode 100644
index 00000000..6cd24f22
--- /dev/null
+++ b/iot_ux/models/ir_actions_report.py
@@ -0,0 +1,62 @@
+from odoo import api, fields, models
+
+
+class IrActionsReport(models.Model):
+ _inherit = "ir.actions.report"
+
+ iot_rule_ids = fields.One2many("ir.actions.report.iot.rule", "report_id", string="IoT Report Rules")
+
+ def report_action(self, docids, data=None, config=True):
+ result = super().report_action(docids, data, config)
+ if result.get("type") != "ir.actions.report":
+ return result
+ if user_rule := self.iot_rule_ids.filtered(lambda r: r.user_id == self.env.user):
+ result["id"] = self.id
+ result["device_ids"] = user_rule.device_id.mapped("identifier")
+ elif self.env.user.iot_device_id:
+ result["id"] = self.id
+ result["device_ids"] = self.env.user.iot_device_id.mapped("identifier")
+
+ return result
+
+
+class IrActionsReportIotRule(models.Model):
+ _name = "ir.actions.report.iot.rule"
+ _description = "Report IoT Rule"
+
+ report_id = fields.Many2one("ir.actions.report", required=True)
+ user_id = fields.Many2one(
+ "res.users",
+ required=True,
+ )
+ device_id = fields.Many2one("iot.device", ondelete="set null", domain="[('type', '=', 'printer')]")
+ skip_dialog = fields.Boolean(default=True)
+ active = fields.Boolean(default=True)
+
+ _unique_report_users = models.Constraint(
+ "UNIQUE(report_id, user_id)",
+ "Only can have one IoT rule per report and user.",
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ res = super().create(vals_list)
+ self.env["iot.channel"].sudo().send_message(
+ {
+ "user_ids": res.mapped("user_id").ids,
+ "report_ids": res.mapped("report_id").ids,
+ },
+ "clear_local_storage",
+ )
+ return res
+
+ def write(self, vals):
+ res = super().write(vals)
+ self.env["iot.channel"].sudo().send_message(
+ {
+ "user_ids": self.mapped("user_id").ids,
+ "report_ids": self.mapped("report_id").ids,
+ },
+ "clear_local_storage",
+ )
+ return res
diff --git a/iot_ux/models/res_users.py b/iot_ux/models/res_users.py
new file mode 100644
index 00000000..85a88227
--- /dev/null
+++ b/iot_ux/models/res_users.py
@@ -0,0 +1,11 @@
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ iot_device_id = fields.Many2one(
+ comodel_name="iot.device",
+ string="Default Printer",
+ domain="[('type', '=', 'printer')]",
+ )
diff --git a/iot_ux/security/ir.model.access.csv b/iot_ux/security/ir.model.access.csv
new file mode 100644
index 00000000..d97de807
--- /dev/null
+++ b/iot_ux/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_iot_device_rule_user,iot rule user,model_ir_actions_report_iot_rule,base.group_user,1,0,0,0
+access_iot_device_rule_admin,iot rule admin,model_ir_actions_report_iot_rule,iot.group_iot_admin,1,1,1,1
diff --git a/iot_ux/static/src/iot_report_action.js b/iot_ux/static/src/iot_report_action.js
new file mode 100644
index 00000000..d7a5e246
--- /dev/null
+++ b/iot_ux/static/src/iot_report_action.js
@@ -0,0 +1,57 @@
+import {
+ IOT_REPORT_PREFERENCE_LOCAL_STORAGE_KEY,
+ setReportIdInBrowserLocalStorage,
+} from "@iot/client_action/delete_local_storage";
+import { browser } from "@web/core/browser/browser";
+import { registry } from "@web/core/registry";
+import { user } from "@web/core/user";
+
+import { getSelectedPrintersForReport, printReport } from "@iot/iot_report_action";
+
+
+export async function getSelectedPrintersForReportUx(reportId, env) {
+ const { orm, action, ui } = env.services;
+ const deviceSettingsByReportId = JSON.parse(browser.localStorage.getItem(IOT_REPORT_PREFERENCE_LOCAL_STORAGE_KEY));
+ const deviceSettings = deviceSettingsByReportId?.[reportId];
+ if (!deviceSettings ) {
+ const rule= await orm.call(
+ "ir.actions.report.iot.rule",
+ "search_read",
+ [[['report_id', '=', reportId], ['user_id', '=', user.userId], ['device_id', '!=', false]],
+ ["skip_dialog", "device_id"]]);
+ if (rule.length > 0) {
+ const newDeviceSettings = {
+ selectedDevices: [rule[0].device_id[0]],
+ skipDialog: rule[0].skip_dialog,
+ };
+ setReportIdInBrowserLocalStorage(reportId, newDeviceSettings);
+ }
+ }
+ return await getSelectedPrintersForReport(reportId, env);
+}
+
+async function iotReportActionHandler(action, options, env) {
+ if (action.device_ids && action.device_ids.length) {
+ action.data ??= {};
+ const args = [action.id, action.context.active_ids, action.data];
+ const reportId = action.id;
+ const printerIds = await getSelectedPrintersForReportUx(reportId, env);
+
+ if (!printerIds) {
+ // If the user does not select any printer, fall back to normal printing
+ return false;
+ }
+
+ env.services.ui.block();
+ // Try longpolling then websocket
+ await printReport(env, args, printerIds);
+ env.services.ui.unblock();
+
+ options.onClose?.();
+ return true;
+ }
+}
+
+registry
+ .category("ir.actions.report handlers")
+ .add("iot_report_action_handler", iotReportActionHandler, {force: true});
diff --git a/iot_ux/views/iot_views.xml b/iot_ux/views/iot_views.xml
new file mode 100644
index 00000000..6dfba059
--- /dev/null
+++ b/iot_ux/views/iot_views.xml
@@ -0,0 +1,19 @@
+
+
+
+ report print
+ iot.device
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iot_ux/views/ir_actions_report.xml b/iot_ux/views/ir_actions_report.xml
new file mode 100644
index 00000000..99a85ace
--- /dev/null
+++ b/iot_ux/views/ir_actions_report.xml
@@ -0,0 +1,23 @@
+
+
+
+ report xml rules
+ ir.actions.report
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iot_ux/views/res_users.xml b/iot_ux/views/res_users.xml
new file mode 100644
index 00000000..13c4cad3
--- /dev/null
+++ b/iot_ux/views/res_users.xml
@@ -0,0 +1,16 @@
+
+
+
+ res.users.preferences.form
+ res.users
+
+
+
+
+
+
+
+
+
+
+