diff --git a/app/__init__.py b/app/__init__.py index d71c42b..4e1f998 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -38,6 +38,7 @@ def create_app(config_name: str = "default") -> Flask: from app.routes.sonoff import sonoff_bp from app.routes.tuya import tuya_bp from app.routes.layouts import layouts_bp + from app.routes.devices import devices_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -48,6 +49,7 @@ def create_app(config_name: str = "default") -> Flask: app.register_blueprint(sonoff_bp) app.register_blueprint(tuya_bp) app.register_blueprint(layouts_bp, url_prefix="/layouts") + app.register_blueprint(devices_bp, url_prefix="/devices") # ── user loader ─────────────────────────────────────────────────────────── from app.models.user import User @@ -76,6 +78,7 @@ def create_app(config_name: str = "default") -> Flask: from app.models import sonoff_device # noqa: F401 from app.models import tuya_device # noqa: F401 from app.models import layout # noqa: F401 + from app.models import device # noqa: F401 db.create_all() _seed_admin(app) _add_entities_column(app) diff --git a/app/models/board.py b/app/models/board.py index 13528f3..647b507 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -65,6 +65,10 @@ class Board(db.Model): "TuyaDevice", back_populates="board", cascade="all, delete-orphan", lazy="dynamic" ) + devices = db.relationship( + "Device", back_populates="board", + cascade="all, delete-orphan", lazy="dynamic" + ) # ── helpers ────────────────────────────────────────────────────── @property diff --git a/app/models/device.py b/app/models/device.py new file mode 100644 index 0000000..96e0d0e --- /dev/null +++ b/app/models/device.py @@ -0,0 +1,105 @@ +"""User-defined device abstraction. + +A Device is a human-friendly alias for a board relay (controllable output) or +a board digital input (sensor/trigger) that the user wants to expose as a named +entity — e.g. "Outdoor Light 1 (Courtyard)" mapped to Board "Garage" / Relay 4. + +Devices provide an intermediate, named layer so they can later be placed on +Layout pages as interactive widgets without the UI needing raw board/relay IDs. +""" +from datetime import datetime +from app import db + + +class Device(db.Model): + __tablename__ = "devices" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False) + description = db.Column(db.String(256), nullable=True) + area = db.Column(db.String(64), nullable=True) # e.g. "Courtyard", "Living Room" + + # Device category — drives default icon and state labels + device_class = db.Column(db.String(32), nullable=False, default="switch") + + # Bootstrap-Icons class override; None → use type default + icon = db.Column(db.String(64), nullable=True) + + # ── Source entity on a board ───────────────────────────────────────────── + board_id = db.Column( + db.Integer, db.ForeignKey("boards.id", ondelete="SET NULL"), nullable=True + ) + entity_type = db.Column(db.String(16), nullable=True) # "relay" | "input" + entity_num = db.Column(db.Integer, nullable=True) # 1-based relay/input index + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # ── relationships ──────────────────────────────────────────────────────── + board = db.relationship("Board", back_populates="devices") + + # ── helpers ────────────────────────────────────────────────────────────── + @property + def is_controllable(self) -> bool: + """True when the source entity is a relay (can be toggled ON/OFF).""" + return self.entity_type == "relay" + + @property + def effective_icon(self) -> str: + """Icon class to use in the UI (custom override or type default).""" + from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES + if self.icon: + return self.icon + if self.entity_type == "relay": + return RELAY_ENTITY_TYPES.get( + self.device_class, RELAY_ENTITY_TYPES["switch"] + )["icon"] + if self.entity_type == "input": + return INPUT_ENTITY_TYPES.get( + self.device_class, INPUT_ENTITY_TYPES["generic"] + )["icon"] + return "bi-cpu" + + @property + def current_state(self): + """Returns the current boolean state or None if unavailable.""" + if not self.board or not self.entity_type or not self.entity_num: + return None + if self.entity_type == "relay": + return self.board.relay_states.get(f"relay_{self.entity_num}", False) + if self.entity_type == "input": + raw = self.board.input_states.get(f"input_{self.entity_num}", True) + return not raw # NC contact: raw True = resting → False = idle + return None + + @property + def state_label(self) -> str: + """Human-readable state string.""" + from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES + state = self.current_state + if state is None: + return "Unknown" + if self.entity_type == "relay": + tdef = RELAY_ENTITY_TYPES.get(self.device_class, RELAY_ENTITY_TYPES["switch"]) + return tdef["on"][1] if state else tdef["off"][1] + if self.entity_type == "input": + tdef = INPUT_ENTITY_TYPES.get(self.device_class, INPUT_ENTITY_TYPES["generic"]) + return tdef["active"][1] if state else tdef["idle"][1] + return "Unknown" + + @property + def state_color(self) -> str: + """Bootstrap color name for current state badge.""" + from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES + state = self.current_state + if state is None: + return "secondary" + if self.entity_type == "relay": + tdef = RELAY_ENTITY_TYPES.get(self.device_class, RELAY_ENTITY_TYPES["switch"]) + return tdef["on"][0] if state else tdef["off"][0] + if self.entity_type == "input": + tdef = INPUT_ENTITY_TYPES.get(self.device_class, INPUT_ENTITY_TYPES["generic"]) + return tdef["active"][0] if state else tdef["idle"][0] + return "secondary" + + def __repr__(self): + return f"" diff --git a/app/routes/devices.py b/app/routes/devices.py new file mode 100644 index 0000000..897a0b0 --- /dev/null +++ b/app/routes/devices.py @@ -0,0 +1,218 @@ +"""Devices module routes. + +Devices are user-defined friendly aliases for board relay/input entities. +E.g. Board "Garage" / Relay 4 → "Outdoor Light 1 (Courtyard)". +""" +import json +from flask import (Blueprint, render_template, redirect, url_for, + flash, request, abort, jsonify) +from flask_login import login_required, current_user +from flask import current_app + +from app import db +from app.models.board import Board +from app.models.device import Device +from app.models.entity_types import (RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES, + ICON_PALETTE) +from app.services.board_service import set_relay, poll_board + +devices_bp = Blueprint("devices", __name__) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _class_choices(entity_type: str) -> list[tuple[str, str]]: + """Return (value, label) pairs for the device_class selector.""" + if entity_type == "relay": + return [(k, v["label"]) for k, v in RELAY_ENTITY_TYPES.items()] + return [(k, v["label"]) for k, v in INPUT_ENTITY_TYPES.items()] + + +# ── list ────────────────────────────────────────────────────────────────────── + +@devices_bp.route("/") +@login_required +def list_devices(): + devices = Device.query.order_by(Device.area, Device.name).all() + return render_template("devices/list.html", devices=devices) + + +# ── add ─────────────────────────────────────────────────────────────────────── + +@devices_bp.route("/add", methods=["GET", "POST"]) +@login_required +def add_device(): + if not current_user.is_admin(): + abort(403) + boards = Board.query.order_by(Board.name).all() + + if request.method == "POST": + name = request.form.get("name", "").strip() + if not name: + flash("Name is required.", "danger") + return render_template( + "devices/edit.html", + device=None, boards=boards, + relay_types=RELAY_ENTITY_TYPES, + input_types=INPUT_ENTITY_TYPES, + icon_palette=ICON_PALETTE, + ) + + board_id = request.form.get("board_id") or None + entity_type = request.form.get("entity_type") or None + entity_num = request.form.get("entity_num") or None + + device = Device( + name = name, + description = request.form.get("description", "").strip() or None, + area = request.form.get("area", "").strip() or None, + device_class= request.form.get("device_class", "switch"), + icon = request.form.get("icon", "").strip() or None, + board_id = int(board_id) if board_id else None, + entity_type = entity_type, + entity_num = int(entity_num) if entity_num else None, + ) + db.session.add(device) + db.session.commit() + flash(f"Device '{name}' created successfully.", "success") + return redirect(url_for("devices.list_devices")) + + return render_template( + "devices/edit.html", + device=None, boards=boards, + relay_types=RELAY_ENTITY_TYPES, + input_types=INPUT_ENTITY_TYPES, + icon_palette=ICON_PALETTE, + ) + + +# ── edit ────────────────────────────────────────────────────────────────────── + +@devices_bp.route("//edit", methods=["GET", "POST"]) +@login_required +def edit_device(device_id: int): + if not current_user.is_admin(): + abort(403) + device = db.get_or_404(Device, device_id) + boards = Board.query.order_by(Board.name).all() + + if request.method == "POST": + name = request.form.get("name", "").strip() + if not name: + flash("Name is required.", "danger") + return render_template( + "devices/edit.html", + device=device, boards=boards, + relay_types=RELAY_ENTITY_TYPES, + input_types=INPUT_ENTITY_TYPES, + icon_palette=ICON_PALETTE, + ) + + board_id = request.form.get("board_id") or None + entity_type = request.form.get("entity_type") or None + entity_num = request.form.get("entity_num") or None + + device.name = name + device.description = request.form.get("description", "").strip() or None + device.area = request.form.get("area", "").strip() or None + device.device_class = request.form.get("device_class", device.device_class) + device.icon = request.form.get("icon", "").strip() or None + device.board_id = int(board_id) if board_id else None + device.entity_type = entity_type + device.entity_num = int(entity_num) if entity_num else None + + db.session.commit() + flash(f"Device '{name}' updated.", "success") + return redirect(url_for("devices.list_devices")) + + return render_template( + "devices/edit.html", + device=device, boards=boards, + relay_types=RELAY_ENTITY_TYPES, + input_types=INPUT_ENTITY_TYPES, + icon_palette=ICON_PALETTE, + ) + + +# ── delete ──────────────────────────────────────────────────────────────────── + +@devices_bp.route("//delete", methods=["POST"]) +@login_required +def delete_device(device_id: int): + if not current_user.is_admin(): + abort(403) + device = db.get_or_404(Device, device_id) + name = device.name + db.session.delete(device) + db.session.commit() + flash(f"Device '{name}' deleted.", "warning") + return redirect(url_for("devices.list_devices")) + + +# ── toggle relay (AJAX) ─────────────────────────────────────────────────────── + +@devices_bp.route("//toggle", methods=["POST"]) +@login_required +def toggle_device(device_id: int): + device = db.get_or_404(Device, device_id) + if not device.is_controllable: + return jsonify({"ok": False, "error": "Device is not controllable."}), 400 + if not device.board: + return jsonify({"ok": False, "error": "No board linked."}), 400 + + # Determine new state (toggle) + current = device.board.relay_states.get(f"relay_{device.entity_num}", False) + new_state = not current + + app = current_app._get_current_object() + ok = set_relay(device.board, device.entity_num, new_state) + + # Re-read updated relay state + poll_board(app, device.board_id) + updated_device = db.session.get(Device, device_id) + + return jsonify({ + "ok": ok, + "state": updated_device.current_state, + "state_label": updated_device.state_label, + "state_color": updated_device.state_color, + }) + + +# ── API: board entity info (for dynamic form population) ───────────────────── + +@devices_bp.route("/api/boards//entities") +@login_required +def board_entities(board_id: int): + """Return relay and input entity info for the given board as JSON.""" + board = db.get_or_404(Board, board_id) + relays = [] + for n in range(1, board.num_relays + 1): + e = board.get_relay_entity(n) + state = board.relay_states.get(f"relay_{n}", False) + relays.append({ + "num": n, + "name": e["name"], + "type": e["type"], + "icon": e["icon"], + "state": state, + "state_label": e["on_label"] if state else e["off_label"], + "on_color": e["on_color"], + "off_color": e["off_color"], + }) + inputs = [] + for n in range(1, board.num_inputs + 1): + e = board.get_input_entity(n) + raw = board.input_states.get(f"input_{n}", True) + active = not raw + inputs.append({ + "num": n, + "name": e["name"], + "type": e["type"], + "icon": e["icon"], + "active": active, + "state_label": e["active_label"] if active else e["idle_label"], + "active_color": e["active_color"], + "idle_color": e["idle_color"], + }) + return jsonify({"relays": relays, "inputs": inputs}) diff --git a/app/templates/base.html b/app/templates/base.html index cf7344f..239efd8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -31,6 +31,12 @@ Boards +
  • + + Devices + +
  • diff --git a/app/templates/devices/_card.html b/app/templates/devices/_card.html new file mode 100644 index 0000000..e434273 --- /dev/null +++ b/app/templates/devices/_card.html @@ -0,0 +1,75 @@ +{% set state = device.current_state %} +{% set controllable = device.is_controllable %} + + diff --git a/app/templates/devices/edit.html b/app/templates/devices/edit.html new file mode 100644 index 0000000..4a968c4 --- /dev/null +++ b/app/templates/devices/edit.html @@ -0,0 +1,473 @@ +{% extends "base.html" %} +{% block title %}{% if device %}Edit Device – {{ device.name }}{% else %}Add Device{% endif %}{% endblock %} + +{% block content %} + + +

    + + {% if device %}Edit Device{% else %}Add Device{% endif %} +

    +

    + Give your device a name and map it to a relay or sensor channel on one of your boards. + Once mapped, toggling the device will control the physical hardware. +

    + +
    + + + + + + +
    + + +
    + + +
    +
    +
    Identity
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + + +
    +
    +
    Device Type
    +

    Sets default icon, state labels and colours. Auto-filled when you pick a channel.

    + +
    +
    + {% for tkey, tdef in relay_types.items() %} + + {% endfor %} +
    +
    + + +
    +
    + + +
    +
    +
    Custom Icon
    + +
    +
    + +
    +
    +
    + + + +
    +
    Leave blank to use device type default.
    +
    +
    + +
    + {% for icon_cls, icon_name in icon_palette %} + + {% endfor %} +
    +
    +
    + +
    + + +
    +
    +
    +
    + Board & Channel Binding +
    +

    + Select a board, then click the relay or sensor you want to bind to this device. + Toggling the device later will directly control the physical hardware. +

    + + +
    + + +
    + + +
    + + + +
    + + Select a board above to see its relay and sensor channels. +
    + + + + + + + + + + +
    +
    +
    +
    + +
    + + +
    + + + Cancel + +
    + +
    +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/app/templates/devices/list.html b/app/templates/devices/list.html new file mode 100644 index 0000000..9ca64a0 --- /dev/null +++ b/app/templates/devices/list.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}Devices – Location Management{% endblock %} + +{% block content %} +
    +

    + Devices +

    + {% if current_user.is_admin() %} + + Add Device + + {% endif %} +
    + +

    + Define named, personalized devices (lights, switches, pumps, sensors…) that map to + specific relay or input channels on your boards. Devices can be placed on Layout pages + as interactive widgets. +

    + +{% if devices %} + +{# Group by area #} +{% set areas = devices | map(attribute='area') | unique | list %} +{% set no_area = devices | selectattr('area', 'none') | list + + devices | selectattr('area', 'equalto', '') | list + + devices | selectattr('area', 'equalto', None) | list %} + +{# Collect non-empty areas #} +{% set named_areas = [] %} +{% for d in devices %} + {% if d.area and d.area != '' and d.area not in named_areas %} + {% set _ = named_areas.append(d.area) %} + {% endif %} +{% endfor %} + +{# Devices with no area #} +{% set ungrouped = [] %} +{% for d in devices %} + {% if not d.area or d.area == '' %} + {% set _ = ungrouped.append(d) %} + {% endif %} +{% endfor %} + +{% for area in named_areas %} +
    + {{ area }} +
    +
    + {% for device in devices %} + {% if device.area == area %} +
    + {% include "devices/_card.html" %} +
    + {% endif %} + {% endfor %} +
    +{% endfor %} + +{% if ungrouped %} + {% if named_areas %} +
    + Other +
    + {% endif %} +
    + {% for device in ungrouped %} +
    + {% include "devices/_card.html" %} +
    + {% endfor %} +
    +{% endif %} + +{% else %} +
    + +

    No devices defined yet.

    + {% if current_user.is_admin() %} + + Add your first device + + {% endif %} +
    +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %}