Add NFC enable/disable support; update devices, Sonoff, and Tuya

This commit is contained in:
2026-04-13 21:35:17 +03:00
parent 86bfecca26
commit 5340f88ffe
15 changed files with 843 additions and 175 deletions

View File

@@ -380,3 +380,27 @@ def nfc_enroll(board_id: int):
else:
flash("Card read OK but failed to push config to board.", "danger")
return redirect(url_for("boards.nfc_management", board_id=board_id))
@boards_bp.route("/<int:board_id>/nfc/enable", methods=["POST"])
@login_required
def nfc_enable(board_id: int):
"""Enable or disable the NFC/Mifare access-control module on the board."""
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
if board.board_type != _NFC_DRIVER_ID:
abort(404)
driver = registry.get(_NFC_DRIVER_ID)
if not driver:
flash("NFC driver not available.", "danger")
return redirect(url_for("boards.nfc_management", board_id=board_id))
enabled = request.form.get("enabled", "0") == "1"
ok = driver.set_nfc_enabled(board, enabled)
if ok:
state_label = "enabled" if enabled else "disabled"
flash(f"NFC access control module {state_label}.", "success")
else:
flash("Failed to change NFC module state — board unreachable.", "danger")
return redirect(url_for("boards.nfc_management", board_id=board_id))

View File

@@ -7,14 +7,15 @@ 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.tuya_device import TuyaDevice
from app.models.sonoff_device import SonoffDevice
from app.models.entity_types import (RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES,
ICON_PALETTE)
from app.services.board_service import set_relay, poll_board
from app.services.board_service import set_relay
devices_bp = Blueprint("devices", __name__)
@@ -61,6 +62,7 @@ def add_device():
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
hardware_device_id = request.form.get("hardware_device_id") or None
device = Device(
name = name,
@@ -71,11 +73,13 @@ def add_device():
board_id = int(board_id) if board_id else None,
entity_type = entity_type,
entity_num = int(entity_num) if entity_num else None,
hardware_device_id = hardware_device_id,
)
device.app_id = Device.generate_app_id(name)
db.session.add(device)
db.session.commit()
flash(f"Device '{name}' created successfully.", "success")
return redirect(url_for("devices.list_devices"))
flash(f"Device '{name}' created. You can now optionally bind it to hardware below.", "success")
return redirect(url_for("devices.edit_device", device_id=device.id))
return render_template(
"devices/edit.html",
@@ -111,6 +115,7 @@ def edit_device(device_id: int):
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
hardware_device_id = request.form.get("hardware_device_id") or None
device.name = name
device.description = request.form.get("description", "").strip() or None
@@ -120,6 +125,19 @@ def edit_device(device_id: int):
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
device.hardware_device_id = hardware_device_id
# Regenerate app_id if name changed and app_id was based on the old name
custom_app_id = request.form.get("app_id", "").strip()
if custom_app_id:
# Accept a manually set app_id only if unique
existing = Device.query.filter(
Device.app_id == custom_app_id, Device.id != device.id
).first()
if not existing:
device.app_id = custom_app_id
elif device.app_id is None:
device.app_id = Device.generate_app_id(name, exclude_id=device.id)
db.session.commit()
flash(f"Device '{name}' updated.", "success")
@@ -160,15 +178,75 @@ def toggle_device(device_id: int):
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)
# ── Sonoff eWeLink toggle ──────────────────────────────────────────────────
if device.entity_type == "sonoff":
from app.drivers.registry import registry
drv = registry.get("sonoff_ewelink")
if not drv:
return jsonify({"ok": False, "error": "Sonoff driver not available."}), 500
sd_id = device.entity_num // 100
channel = device.entity_num % 100
sd = db.session.get(SonoffDevice, sd_id)
if not sd:
return jsonify({"ok": False, "error": "Sonoff device not found."}), 404
if not sd.is_online:
return jsonify({"ok": False, "error": "Device is offline."})
current_state = sd.get_channel_state(channel)
ok = drv.set_device_channel(device.board, sd.device_id, channel, not current_state)
updated = db.session.get(Device, device_id)
error_msg = None if ok else "Cloud control failed. Device may be offline."
return jsonify({
"ok": ok,
"state": updated.current_state,
"state_label": updated.state_label,
"state_color": updated.state_color,
"error": error_msg,
})
# ── Tuya Cloud toggle ────────────────────────────────────────────────────
if device.entity_type == "tuya":
from app.drivers.registry import registry
drv = registry.get("tuya_cloud")
if not drv:
return jsonify({"ok": False, "error": "Tuya driver not available."}), 500
td_id = device.entity_num // 100
channel = (device.entity_num % 100) - 1 # 0-based index
td = db.session.get(TuyaDevice, td_id)
if not td:
return jsonify({"ok": False, "error": "Tuya device not found."}), 404
dps = td.switch_dps
if channel >= len(dps):
return jsonify({"ok": False, "error": "Invalid channel index."}), 400
dp_code = dps[channel]
ok = drv.toggle_dp(device.board, td.device_id, dp_code)
updated = db.session.get(Device, device_id)
return jsonify({
"ok": ok,
"state": updated.current_state,
"state_label": updated.state_label,
"state_color": updated.state_color,
})
# ── Generic relay toggle ─────────────────────────────────────────────────
# Optimistically write the new state to DB first (same pattern as boards.py
# toggle_relay_view) so the device card reflects the change immediately.
# poll_board() skips relay state updates for online boards by design, so
# without this the card would always show the stale pre-toggle state.
board = device.board
states = board.relay_states
current = states.get(f"relay_{device.entity_num}", False)
new_state = not current
states[f"relay_{device.entity_num}"] = new_state
board.relay_states = states
db.session.commit()
app = current_app._get_current_object()
ok = set_relay(device.board, device.entity_num, new_state)
ok = set_relay(board, device.entity_num, new_state)
if not ok:
# Hardware command failed — roll back the optimistic state
states[f"relay_{device.entity_num}"] = current
board.relay_states = states
db.session.commit()
# Re-read updated relay state
poll_board(app, device.board_id)
updated_device = db.session.get(Device, device_id)
return jsonify({
@@ -186,6 +264,76 @@ def toggle_device(device_id: int):
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)
# ── Tuya Cloud boards expose TuyaDevice rows, not relay/input channels ──
if board.board_type == "tuya_cloud":
from app.drivers.tuya_cloud.driver import KIND_ICON
tuya_devs = (
TuyaDevice.query.filter_by(board_id=board_id)
.order_by(TuyaDevice.name)
.all()
)
relays = []
for td in tuya_devs:
dps = td.switch_dps
for ch_idx, dp_code in enumerate(dps):
num = td.id * 100 + (ch_idx + 1)
state = bool(td.status.get(dp_code, False))
name = td.name if len(dps) == 1 else f"{td.name} Ch {ch_idx + 1}"
relays.append({
"num": num,
"name": name,
"type": td.kind,
"icon": td.kind_icon_cls,
"state": state,
"state_label": "ON" if state else "OFF",
"on_color": "success",
"off_color": "secondary",
"entity_type": "tuya",
"device_id": td.device_id,
})
return jsonify({"relays": relays, "inputs": [], "board_type": "tuya_cloud"})
# ── Sonoff eWeLink boards expose SonoffDevice rows ─────────────────────────
if board.board_type == "sonoff_ewelink":
from app.models.sonoff_device import UIID_INFO
KIND_ICON_SONOFF = {
"switch": "bi-toggles",
"light": "bi-lightbulb-fill",
"fan": "bi-fan",
"sensor": "bi-thermometer-half",
}
sonoff_devs = (
SonoffDevice.query.filter_by(board_id=board_id)
.order_by(SonoffDevice.name)
.all()
)
relays = []
for sd in sonoff_devs:
uiid_meta = UIID_INFO.get(sd.uiid, {})
kind = uiid_meta.get("kind", "switch")
if kind not in ("switch", "light", "fan"):
continue # sensors / remotes are not controllable
icon = KIND_ICON_SONOFF.get(kind, "bi-toggles")
for ch in range(sd.num_channels):
num = sd.id * 100 + ch
state = sd.get_channel_state(ch)
name = sd.name if sd.num_channels == 1 else f"{sd.name} Ch {ch + 1}"
relays.append({
"num": num,
"name": name,
"type": kind,
"icon": icon,
"state": state,
"state_label": "ON" if state else "OFF",
"on_color": "success",
"off_color": "secondary",
"entity_type": "sonoff",
"device_id": sd.device_id,
})
return jsonify({"relays": relays, "inputs": [], "board_type": "sonoff_ewelink"})
# ── Generic relay/input boards ──────────────────────────────────────────
relays = []
for n in range(1, board.num_relays + 1):
e = board.get_relay_entity(n)

View File

@@ -11,6 +11,7 @@ URL structure:
POST /tuya/<board_id>/device/<device_id>/dp/<dp>/toggle toggle a DP
POST /tuya/<board_id>/device/<device_id>/rename rename a device
"""
import logging
from flask import (
Blueprint, abort, flash, jsonify, redirect,
render_template, request, url_for,
@@ -25,6 +26,8 @@ from app.drivers.tuya_cloud.driver import (
TUYA_CLIENT_ID, TUYA_SCHEMA, category_kind, KIND_ICON,
)
logger = logging.getLogger(__name__)
tuya_bp = Blueprint("tuya", __name__, url_prefix="/tuya")
@@ -101,7 +104,10 @@ def generate_qr(board_id: int):
response = lc.qr_code(TUYA_CLIENT_ID, TUYA_SCHEMA, user_code)
if not response.get("success"):
return jsonify({"ok": False, "error": response.get("msg", "QR generation failed")}), 400
err_msg = response.get("msg", "QR generation failed")
logger.error("Tuya qr_code() failed for board %s: %s | full response: %s",
board_id, err_msg, response)
return jsonify({"ok": False, "error": err_msg}), 400
qr_token = response["result"]["qrcode"]
# The URI that Smart Life / Tuya Smart app decodes from the QR: