Add Tuya Cloud integration; 2-step add-board wizard; DP value formatting
- Tuya Cloud Gateway driver (tuya-device-sharing-sdk):
- QR-code auth flow: user-provided user_code, terminal_id/endpoint
returned by Tuya login_result (mirrors HA implementation)
- Device sync, toggle DP, rename, per-device detail page
- category → kind mapping; detect_switch_dps helper
- format_dp_value: temperature (÷10 + °C/°F), humidity (+ %)
registered as 'tuya_dp' Jinja2 filter
- TuyaDevice model (tuya_devices table)
- Templates:
- tuya/gateway.html: device grid with live-reading sensor cards
(config/threshold keys hidden from card, shown on detail page)
- tuya/device.html: full status table with formatted DP values
- tuya/auth_settings.html: user_code input + QR scan flow
- Add-board wizard refactored to 2-step flow:
- Step 1: choose board type (Cloud Gateways vs Hardware)
- Step 2: type-specific fields; gateways skip IP/relay fields
- Layout builder: Tuya chip support (makeTuyaChip, tuya_update socket)
- requirements.txt: tuya-device-sharing-sdk, cryptography
This commit is contained in:
@@ -36,6 +36,7 @@ def create_app(config_name: str = "default") -> Flask:
|
||||
from app.routes.api import api_bp
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.sonoff import sonoff_bp
|
||||
from app.routes.tuya import tuya_bp
|
||||
from app.routes.layouts import layouts_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
@@ -45,6 +46,7 @@ def create_app(config_name: str = "default") -> Flask:
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||
app.register_blueprint(sonoff_bp)
|
||||
app.register_blueprint(tuya_bp)
|
||||
app.register_blueprint(layouts_bp, url_prefix="/layouts")
|
||||
|
||||
# ── user loader ───────────────────────────────────────────────────────────
|
||||
@@ -59,11 +61,20 @@ def create_app(config_name: str = "default") -> Flask:
|
||||
registry.discover()
|
||||
app.logger.info("Board drivers loaded: %s", [d.DRIVER_ID for d in registry.all()])
|
||||
|
||||
# ── Jinja filters ─────────────────────────────────────────────────────────
|
||||
from app.drivers.tuya_cloud.driver import format_dp_value as _tuya_fmt
|
||||
|
||||
@app.template_filter("tuya_dp")
|
||||
def _tuya_dp_filter(value, dp_code: str, status=None):
|
||||
"""Format a raw Tuya DP value for display (temperature scaling, units…)."""
|
||||
return _tuya_fmt(dp_code, value, status)
|
||||
|
||||
# ── create tables & seed admin on first run ───────────────────────────────
|
||||
with app.app_context():
|
||||
# Import all models so their tables are registered before create_all
|
||||
from app.models import board, user, workflow # noqa: F401
|
||||
from app.models import sonoff_device # noqa: F401
|
||||
from app.models import tuya_device # noqa: F401
|
||||
from app.models import layout # noqa: F401
|
||||
db.create_all()
|
||||
_seed_admin(app)
|
||||
|
||||
0
app/drivers/tuya_cloud/__init__.py
Normal file
0
app/drivers/tuya_cloud/__init__.py
Normal file
297
app/drivers/tuya_cloud/driver.py
Normal file
297
app/drivers/tuya_cloud/driver.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Tuya Cloud Gateway Driver.
|
||||
|
||||
Implements the Tuya Device Sharing SDK integration. A single
|
||||
"board" record represents an entire Tuya / Smart Life account.
|
||||
Each discovered Tuya device is stored as a TuyaDevice record.
|
||||
|
||||
Auth flow (QR-code based):
|
||||
1. User provides any short string as their "user_code".
|
||||
2. Driver calls LoginControl().qr_code() → receives a QR token.
|
||||
3. User scans the QR with the Tuya/Smart Life app.
|
||||
4. Driver polls LoginControl().login_result() until success.
|
||||
5. Tokens (access_token, refresh_token, etc.) are saved in board.config.
|
||||
|
||||
Device categories → kinds mapping follows the Tuya OpenAPI category definitions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.drivers.base import BoardDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.board import Board
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Tuya sharing app credentials (same as Home Assistant) ─────────────────────
|
||||
TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke"
|
||||
TUYA_SCHEMA = "haauthorize"
|
||||
|
||||
# ── Category code → kind ──────────────────────────────────────────────────────
|
||||
CATEGORY_KIND: dict[str, str] = {
|
||||
# Switches / sockets / power strips
|
||||
"kg": "switch", "cz": "switch", "pc": "switch", "tdq": "switch",
|
||||
"tgkg": "switch", "tgq": "switch", "dlq": "switch", "zndb": "switch",
|
||||
# Lights
|
||||
"dj": "light", "dd": "light", "xdd": "light", "fwd": "light",
|
||||
"dc": "light", "tydj": "light", "gyd": "light",
|
||||
# Fans
|
||||
"fs": "fan", "fsd": "fan",
|
||||
# Sensors
|
||||
"wsdcg": "sensor", "co2bj": "sensor", "mcs": "sensor",
|
||||
"pir": "sensor", "ldcg": "sensor", "pm2.5": "sensor",
|
||||
# Covers / blinds
|
||||
"cl": "cover", "clkg": "cover",
|
||||
}
|
||||
|
||||
KIND_ICON: dict[str, str] = {
|
||||
"switch": "bi-toggles",
|
||||
"light": "bi-lightbulb-fill",
|
||||
"fan": "bi-fan",
|
||||
"sensor": "bi-thermometer-half",
|
||||
"cover": "bi-door-open",
|
||||
}
|
||||
|
||||
|
||||
def category_kind(cat: str) -> str:
|
||||
return CATEGORY_KIND.get(cat, "switch")
|
||||
|
||||
|
||||
# ── DP value formatting ────────────────────────────────────────────────────────
|
||||
|
||||
# DP code substrings that indicate a temperature value stored as integer × 10
|
||||
_TEMP_DP_SUBSTRINGS = ("temp",)
|
||||
# Substrings that *contain* "temp" but are NOT scaled temperature readings
|
||||
_TEMP_DP_EXCLUDE = ("unit_convert", "alarm")
|
||||
# DP code substrings that carry a raw humidity percentage (0-100)
|
||||
_HUMID_DP_SUBSTRINGS = ("humidity", "va_humidity", "hum_set", "hum_alarm")
|
||||
|
||||
|
||||
def format_dp_value(dp_code: str, value, status: dict | None = None):
|
||||
"""Return a human-readable representation of a Tuya DP value.
|
||||
|
||||
Rules
|
||||
-----
|
||||
* **Temperature DPs** (any code containing ``"temp"`` but not
|
||||
``"unit_convert"`` or ``"alarm"``): raw integer is divided by 10
|
||||
and the unit symbol (°C / °F) is appended based on the device's
|
||||
``temp_unit_convert`` status key.
|
||||
* **Humidity DPs**: raw integer is shown with a ``%`` suffix.
|
||||
* **Booleans**: returned unchanged so the template can render badges.
|
||||
* Everything else: returned unchanged.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value # template renders True/False as coloured badges
|
||||
|
||||
code = dp_code.lower()
|
||||
|
||||
# Temperature (Tuya encodes as integer × 10)
|
||||
if (
|
||||
any(sub in code for sub in _TEMP_DP_SUBSTRINGS)
|
||||
and not any(exc in code for exc in _TEMP_DP_EXCLUDE)
|
||||
and isinstance(value, (int, float))
|
||||
):
|
||||
unit_raw = (status or {}).get("temp_unit_convert", "c")
|
||||
unit_sym = "°F" if str(unit_raw).lower() == "f" else "°C"
|
||||
return f"{value / 10:.1f} {unit_sym}"
|
||||
|
||||
# Humidity (raw 0-100 %)
|
||||
if any(sub in code for sub in _HUMID_DP_SUBSTRINGS) and isinstance(value, (int, float)):
|
||||
return f"{value} %"
|
||||
|
||||
return value # pass through; template handles display
|
||||
|
||||
|
||||
def detect_switch_dps(device) -> list[str]:
|
||||
"""Return ordered list of writable boolean switch DP codes for a device.
|
||||
|
||||
Looks at device.function (writable DPs) and device.status keys.
|
||||
Returns e.g. ['switch'], ['switch_1','switch_2'], etc.
|
||||
"""
|
||||
funcs = device.function if hasattr(device, "function") and device.function else {}
|
||||
status = device.status if hasattr(device, "status") and device.status else {}
|
||||
candidates = set(funcs.keys()) | set(status.keys())
|
||||
|
||||
dps: list[str] = []
|
||||
if "switch" in candidates:
|
||||
dps.append("switch")
|
||||
for i in range(1, 10):
|
||||
key = f"switch_{i}"
|
||||
if key in candidates:
|
||||
dps.append(key)
|
||||
|
||||
# Fallback: treat "led_switch" / "fan_switch" as single channel
|
||||
if not dps:
|
||||
for fallback in ("led_switch", "fan_switch", "switch_led"):
|
||||
if fallback in candidates:
|
||||
dps.append(fallback)
|
||||
break
|
||||
|
||||
return dps or ["switch"]
|
||||
|
||||
|
||||
def build_manager(board: "Board"):
|
||||
"""Construct a tuya_sharing.Manager from this board's stored config.
|
||||
|
||||
The Manager handles token-refresh automatically. Any refreshed token
|
||||
is written back to board.config so the next save() persists it.
|
||||
"""
|
||||
try:
|
||||
from tuya_sharing import Manager, SharingTokenListener
|
||||
except ImportError:
|
||||
logger.error("tuya-device-sharing-sdk not installed; run: pip install tuya-device-sharing-sdk")
|
||||
return None
|
||||
|
||||
cfg = board.config
|
||||
token_info = cfg.get("tuya_token_info")
|
||||
user_code = cfg.get("tuya_user_code", "")
|
||||
terminal_id = cfg.get("tuya_terminal_id", "")
|
||||
endpoint = cfg.get("tuya_endpoint", "https://apigw.iotbing.com")
|
||||
|
||||
if not token_info or not user_code:
|
||||
logger.debug("Board %d has no Tuya token — skipping manager creation", board.id)
|
||||
return None
|
||||
|
||||
class _Listener(SharingTokenListener):
|
||||
def update_token(self, new_token_info: dict):
|
||||
# Merge refreshed token back; caller must db.session.commit() later
|
||||
cfg["tuya_token_info"] = new_token_info
|
||||
board.config = cfg
|
||||
|
||||
try:
|
||||
return Manager(
|
||||
client_id=TUYA_CLIENT_ID,
|
||||
user_code=user_code,
|
||||
terminal_id=terminal_id,
|
||||
end_point=endpoint,
|
||||
token_response=token_info,
|
||||
listener=_Listener(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Cannot create Tuya Manager for board %d: %s", board.id, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ── Driver class ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TuyaCloudDriver(BoardDriver):
|
||||
DRIVER_ID = "tuya_cloud"
|
||||
DISPLAY_NAME = "Tuya Cloud Gateway"
|
||||
DESCRIPTION = "Control Tuya / Smart Life devices via the Tuya Device Sharing cloud API"
|
||||
DEFAULT_NUM_RELAYS = 0
|
||||
DEFAULT_NUM_INPUTS = 0
|
||||
|
||||
# ── Abstract stubs (not used for gateway boards) ──────────────────────────
|
||||
def get_relay_status(self, board, relay_num): return None
|
||||
def set_relay(self, board, relay_num, state): return False
|
||||
def toggle_relay(self, board, relay_num): return None
|
||||
def register_webhook(self, board, callback_url): return True
|
||||
|
||||
# ── Core poll ─────────────────────────────────────────────────────────────
|
||||
def poll(self, board: "Board") -> dict:
|
||||
"""Sync device list + states from Tuya cloud."""
|
||||
try:
|
||||
self.sync_devices(board)
|
||||
board.is_online = True
|
||||
except Exception as exc:
|
||||
logger.warning("Tuya poll failed for board %d: %s", board.id, exc)
|
||||
board.is_online = False
|
||||
return {"relay_states": {}, "input_states": {}, "is_online": board.is_online}
|
||||
|
||||
# ── Sync ──────────────────────────────────────────────────────────────────
|
||||
def sync_devices(self, board: "Board") -> int:
|
||||
"""Fetch the device list from Tuya cloud and upsert TuyaDevice rows."""
|
||||
from app import db
|
||||
from app.models.tuya_device import TuyaDevice
|
||||
from datetime import datetime
|
||||
|
||||
mgr = build_manager(board)
|
||||
if mgr is None:
|
||||
return 0
|
||||
|
||||
mgr.update_device_cache()
|
||||
|
||||
# Persist any token that was refreshed during the update
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
existing: dict[str, TuyaDevice] = {
|
||||
d.device_id: d
|
||||
for d in TuyaDevice.query.filter_by(board_id=board.id).all()
|
||||
}
|
||||
|
||||
synced = 0
|
||||
for device_id, device in mgr.device_map.items():
|
||||
dev = existing.get(device_id)
|
||||
if dev is None:
|
||||
dev = TuyaDevice(board_id=board.id, device_id=device_id)
|
||||
db.session.add(dev)
|
||||
|
||||
if not dev.name:
|
||||
dev.name = device.name or device_id
|
||||
dev.category = device.category or ""
|
||||
dev.product_name = getattr(device, "product_name", "") or ""
|
||||
dev.is_online = device.online
|
||||
dev.kind = category_kind(dev.category)
|
||||
dev.kind_icon_cls = KIND_ICON.get(dev.kind, "bi-toggles")
|
||||
|
||||
switch_dps = detect_switch_dps(device)
|
||||
dev.num_channels = len(switch_dps)
|
||||
dev.switch_dps_json = json.dumps(switch_dps)
|
||||
dev.status_json = json.dumps(dict(device.status) if device.status else {})
|
||||
|
||||
if device.online:
|
||||
dev.last_seen = datetime.utcnow()
|
||||
synced += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info("Board %d: synced %d Tuya devices", board.id, synced)
|
||||
return synced
|
||||
|
||||
# ── Control ───────────────────────────────────────────────────────────────
|
||||
def set_dp(self, board: "Board", device_id: str, dp_code: str, value) -> bool:
|
||||
"""Send a DP command (e.g. switch=True) to a Tuya device."""
|
||||
mgr = build_manager(board)
|
||||
if mgr is None:
|
||||
return False
|
||||
try:
|
||||
mgr.send_commands(device_id, [{"code": dp_code, "value": value}])
|
||||
self._apply_local(board, device_id, dp_code, value)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Tuya set_dp %s.%s=%s failed: %s", device_id, dp_code, value, exc)
|
||||
return False
|
||||
|
||||
def toggle_dp(self, board: "Board", device_id: str, dp_code: str) -> bool:
|
||||
"""Toggle the boolean state of a DP."""
|
||||
from app.models.tuya_device import TuyaDevice
|
||||
dev = TuyaDevice.query.filter_by(
|
||||
board_id=board.id, device_id=device_id
|
||||
).first()
|
||||
if not dev:
|
||||
return False
|
||||
current_val = dev.status.get(dp_code, False)
|
||||
return self.set_dp(board, device_id, dp_code, not bool(current_val))
|
||||
|
||||
# ── Private ───────────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _apply_local(board: "Board", device_id: str, dp_code: str, value) -> None:
|
||||
"""Update the cached status immediately so the UI is consistent."""
|
||||
from app import db
|
||||
from app.models.tuya_device import TuyaDevice
|
||||
dev = TuyaDevice.query.filter_by(
|
||||
board_id=board.id, device_id=device_id
|
||||
).first()
|
||||
if dev:
|
||||
s = dev.status
|
||||
s[dp_code] = value
|
||||
dev.status = s
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
5
app/drivers/tuya_cloud/manifest.json
Normal file
5
app/drivers/tuya_cloud/manifest.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"driver_id": "tuya_cloud",
|
||||
"name": "Tuya Cloud Gateway",
|
||||
"description": "Control Tuya / Smart Life devices via the Tuya Device Sharing cloud API"
|
||||
}
|
||||
@@ -56,6 +56,10 @@ class Board(db.Model):
|
||||
"SonoffDevice", back_populates="board",
|
||||
cascade="all, delete-orphan", lazy="dynamic"
|
||||
)
|
||||
tuya_devices = db.relationship(
|
||||
"TuyaDevice", back_populates="board",
|
||||
cascade="all, delete-orphan", lazy="dynamic"
|
||||
)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
@property
|
||||
|
||||
78
app/models/tuya_device.py
Normal file
78
app/models/tuya_device.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""TuyaDevice model – represents a single Tuya / Smart Life sub-device
|
||||
belonging to a Tuya Cloud Gateway board.
|
||||
|
||||
Each board (gateway) has many TuyaDevice rows, one per cloud device.
|
||||
Controllable "channels" are boolean switch Data-Points (DPs) identified
|
||||
by dp_code strings like "switch", "switch_1", "switch_2", …
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class TuyaDevice(db.Model):
|
||||
__tablename__ = "tuya_devices"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
board_id = db.Column(db.Integer, db.ForeignKey("boards.id"), nullable=False)
|
||||
|
||||
# Tuya global device ID (e.g. "bf0123456789abc")
|
||||
device_id = db.Column(db.String(64), nullable=False)
|
||||
# User-visible name
|
||||
name = db.Column(db.String(128), default="")
|
||||
# Tuya product category code (e.g. "kg"=switch, "dj"=light)
|
||||
category = db.Column(db.String(32), default="")
|
||||
# Full product name from Tuya cloud
|
||||
product_name = db.Column(db.String(128), default="")
|
||||
# Derived kind label: switch / light / fan / sensor / cover
|
||||
kind = db.Column(db.String(32), default="switch")
|
||||
# Bootstrap icon class for this kind
|
||||
kind_icon_cls = db.Column(db.String(64), default="bi-toggles")
|
||||
# Number of controllable switch channels
|
||||
num_channels = db.Column(db.Integer, default=1)
|
||||
# JSON list of switch DP codes in channel order, e.g. ["switch_1","switch_2"]
|
||||
switch_dps_json = db.Column(db.Text, default='["switch"]')
|
||||
# Full device status as JSON dict, e.g. {"switch":true,"countdown":0}
|
||||
status_json = db.Column(db.Text, default="{}")
|
||||
# Whether device is currently online
|
||||
is_online = db.Column(db.Boolean, default=False)
|
||||
last_seen = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
board = db.relationship("Board", back_populates="tuya_devices")
|
||||
|
||||
# ── status helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def status(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.status_json or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
@status.setter
|
||||
def status(self, value: dict):
|
||||
self.status_json = json.dumps(value)
|
||||
|
||||
# ── switch-DP helpers ─────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def switch_dps(self) -> list[str]:
|
||||
"""Ordered list of switch DP codes, e.g. ['switch_1', 'switch_2']."""
|
||||
try:
|
||||
v = json.loads(self.switch_dps_json or '["switch"]')
|
||||
return v if v else ["switch"]
|
||||
except (ValueError, TypeError):
|
||||
return ["switch"]
|
||||
|
||||
def get_channel_state(self, idx: int) -> bool:
|
||||
"""Return the on/off state of the switch channel at *idx*."""
|
||||
dps = self.switch_dps
|
||||
if not dps:
|
||||
return False
|
||||
dp_code = dps[idx] if idx < len(dps) else dps[0]
|
||||
return bool(self.status.get(dp_code, False))
|
||||
|
||||
def dp_for_channel(self, idx: int) -> str:
|
||||
"""Return the dp_code for channel at *idx*."""
|
||||
dps = self.switch_dps
|
||||
return dps[idx] if dps and idx < len(dps) else dps[0] if dps else "switch"
|
||||
@@ -32,9 +32,11 @@ def list_boards():
|
||||
@login_required
|
||||
def board_detail(board_id: int):
|
||||
board = db.get_or_404(Board, board_id)
|
||||
# Sonoff eWeLink gateway boards have their own page
|
||||
# Gateway boards have their own dedicated page
|
||||
if board.board_type == "sonoff_ewelink":
|
||||
return redirect(url_for("sonoff.gateway", board_id=board_id))
|
||||
if board.board_type == "tuya_cloud":
|
||||
return redirect(url_for("tuya.gateway", board_id=board_id))
|
||||
# Refresh states from device
|
||||
poll_board(current_app._get_current_object(), board_id)
|
||||
board = db.session.get(Board, board_id)
|
||||
@@ -57,14 +59,17 @@ def add_board():
|
||||
num_relays = int(request.form.get("num_relays", 4))
|
||||
num_inputs = int(request.form.get("num_inputs", 4))
|
||||
|
||||
# Sonoff gateway doesn't need a real host address
|
||||
is_gateway = board_type == "sonoff_ewelink"
|
||||
# Gateway boards don't need a real host address
|
||||
is_gateway = board_type in ("sonoff_ewelink", "tuya_cloud")
|
||||
if not name or (not host and not is_gateway):
|
||||
flash("Name and host are required.", "danger")
|
||||
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
|
||||
|
||||
if is_gateway:
|
||||
host = host or "ewelink.cloud"
|
||||
if board_type == "tuya_cloud":
|
||||
host = host or "openapi.tuyaeu.com"
|
||||
else:
|
||||
host = host or "ewelink.cloud"
|
||||
num_relays = 0
|
||||
num_inputs = 0
|
||||
|
||||
@@ -84,6 +89,11 @@ def add_board():
|
||||
register_webhook(board, server_url)
|
||||
|
||||
flash(f"Board '{name}' added successfully.", "success")
|
||||
# Send gateway boards straight to their auth/settings page
|
||||
if board_type == "sonoff_ewelink":
|
||||
return redirect(url_for("sonoff.auth_settings", board_id=board.id))
|
||||
if board_type == "tuya_cloud":
|
||||
return redirect(url_for("tuya.auth_settings", board_id=board.id))
|
||||
return redirect(url_for("boards.board_detail", board_id=board.id))
|
||||
|
||||
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
|
||||
|
||||
@@ -9,6 +9,7 @@ from app import db
|
||||
from app.models.layout import Layout
|
||||
from app.models.board import Board
|
||||
from app.models.sonoff_device import SonoffDevice
|
||||
from app.models.tuya_device import TuyaDevice
|
||||
|
||||
layouts_bp = Blueprint("layouts", __name__)
|
||||
|
||||
@@ -53,6 +54,7 @@ def builder(layout_id: int):
|
||||
for b in boards:
|
||||
bd = {"id": b.id, "name": b.name, "online": b.is_online,
|
||||
"relays": [], "inputs": [], "sonoff_channels": [],
|
||||
"tuya_channels": [],
|
||||
"board_type": b.board_type}
|
||||
|
||||
# ── Standard relay/input boards ──────────────────────────────────────
|
||||
@@ -104,6 +106,33 @@ def builder(layout_id: int):
|
||||
"isOnline": dev.is_online,
|
||||
})
|
||||
|
||||
# ── Tuya Cloud sub-devices ────────────────────────────────────────────
|
||||
if b.board_type == "tuya_cloud":
|
||||
TUYA_KIND_ICON = {
|
||||
"switch": "bi-toggles",
|
||||
"light": "bi-lightbulb-fill",
|
||||
"fan": "bi-fan",
|
||||
"sensor": "bi-thermometer-half",
|
||||
"cover": "bi-door-open",
|
||||
}
|
||||
tuya_devs = TuyaDevice.query.filter_by(board_id=b.id).order_by(
|
||||
TuyaDevice.name).all()
|
||||
for dev in tuya_devs:
|
||||
icon = TUYA_KIND_ICON.get(dev.kind, "bi-plug")
|
||||
for dp in dev.switch_dps:
|
||||
idx = dev.switch_dps.index(dp)
|
||||
label = (dev.name if dev.num_channels == 1
|
||||
else f"{dev.name} – Ch{idx + 1}")
|
||||
bd["tuya_channels"].append({
|
||||
"deviceId": dev.device_id,
|
||||
"channel": dp, # dp_code string, e.g. "switch_1"
|
||||
"name": label,
|
||||
"icon": icon,
|
||||
"kind": dev.kind,
|
||||
"isOn": dev.status.get(dp, False),
|
||||
"isOnline": dev.is_online,
|
||||
})
|
||||
|
||||
boards_data.append(bd)
|
||||
|
||||
return render_template(
|
||||
|
||||
250
app/routes/tuya.py
Normal file
250
app/routes/tuya.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Tuya Cloud Gateway routes.
|
||||
|
||||
URL structure:
|
||||
GET /tuya/<board_id> – gateway overview
|
||||
GET /tuya/<board_id>/auth – auth settings page
|
||||
POST /tuya/<board_id>/auth/qr – (AJAX) generate QR code
|
||||
GET /tuya/<board_id>/auth/poll – (AJAX) poll login result
|
||||
POST /tuya/<board_id>/auth/save – (AJAX) persist tokens
|
||||
POST /tuya/<board_id>/sync – sync devices from cloud
|
||||
GET /tuya/<board_id>/device/<device_id> – device detail
|
||||
POST /tuya/<board_id>/device/<device_id>/dp/<dp>/toggle – toggle a DP
|
||||
POST /tuya/<board_id>/device/<device_id>/rename – rename a device
|
||||
"""
|
||||
from flask import (
|
||||
Blueprint, abort, flash, jsonify, redirect,
|
||||
render_template, request, url_for,
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app import db, socketio
|
||||
from app.models.board import Board
|
||||
from app.models.tuya_device import TuyaDevice
|
||||
from app.drivers.registry import registry
|
||||
from app.drivers.tuya_cloud.driver import (
|
||||
TUYA_CLIENT_ID, TUYA_SCHEMA, category_kind, KIND_ICON,
|
||||
)
|
||||
|
||||
tuya_bp = Blueprint("tuya", __name__, url_prefix="/tuya")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_gateway(board_id: int) -> Board:
|
||||
board = db.get_or_404(Board, board_id)
|
||||
if board.board_type != "tuya_cloud":
|
||||
abort(404)
|
||||
return board
|
||||
|
||||
|
||||
def _get_driver(board: Board):
|
||||
drv = registry.get("tuya_cloud")
|
||||
if drv is None:
|
||||
abort(500)
|
||||
return drv
|
||||
|
||||
|
||||
# ── Gateway overview ──────────────────────────────────────────────────────────
|
||||
|
||||
@tuya_bp.route("/<int:board_id>")
|
||||
@login_required
|
||||
def gateway(board_id: int):
|
||||
board = _get_gateway(board_id)
|
||||
devices = (
|
||||
TuyaDevice.query.filter_by(board_id=board_id)
|
||||
.order_by(TuyaDevice.name)
|
||||
.all()
|
||||
)
|
||||
has_token = bool(board.config.get("tuya_token_info"))
|
||||
return render_template(
|
||||
"tuya/gateway.html",
|
||||
board=board,
|
||||
devices=devices,
|
||||
has_token=has_token,
|
||||
)
|
||||
|
||||
|
||||
# ── Auth settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/auth")
|
||||
@login_required
|
||||
def auth_settings(board_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = _get_gateway(board_id)
|
||||
return render_template("tuya/auth_settings.html", board=board)
|
||||
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/auth/qr", methods=["POST"])
|
||||
@login_required
|
||||
def generate_qr(board_id: int):
|
||||
"""AJAX: generate a new QR-code token for this board.
|
||||
|
||||
The caller must supply a ``user_code`` in the JSON body. This is a
|
||||
meaningful identifier chosen by the user (e.g. a short string they
|
||||
recognise) that Tuya uses to link the scan to their account. It must
|
||||
**not** be auto-generated – Tuya validates it server-side.
|
||||
"""
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
_get_gateway(board_id)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
user_code = data.get("user_code", "").strip()
|
||||
|
||||
if not user_code:
|
||||
return jsonify({"ok": False, "error": "A user code is required to generate the QR code."}), 400
|
||||
|
||||
try:
|
||||
from tuya_sharing import LoginControl
|
||||
lc = LoginControl()
|
||||
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
|
||||
|
||||
qr_token = response["result"]["qrcode"]
|
||||
# The URI that Smart Life / Tuya Smart app decodes from the QR:
|
||||
qr_data = f"tuyaSmart--qrLogin?token={qr_token}"
|
||||
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"qr_data": qr_data,
|
||||
"qr_token": qr_token,
|
||||
"user_code": user_code,
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/auth/poll")
|
||||
@login_required
|
||||
def poll_login(board_id: int):
|
||||
"""AJAX: check whether the user has scanned the QR code yet."""
|
||||
qr_token = request.args.get("token", "")
|
||||
user_code = request.args.get("user_code", "")
|
||||
|
||||
if not qr_token or not user_code:
|
||||
return jsonify({"ok": False, "error": "Missing token or user_code"}), 400
|
||||
|
||||
try:
|
||||
from tuya_sharing import LoginControl
|
||||
lc = LoginControl()
|
||||
ret, info = lc.login_result(qr_token, TUYA_CLIENT_ID, user_code)
|
||||
|
||||
if ret:
|
||||
return jsonify({"ok": True, "info": info})
|
||||
return jsonify({"ok": False, "pending": True})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/auth/save", methods=["POST"])
|
||||
@login_required
|
||||
def save_auth(board_id: int):
|
||||
"""AJAX: persist tokens after successful QR scan."""
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = _get_gateway(board_id)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
info = data.get("info", {})
|
||||
user_code = data.get("user_code", "")
|
||||
|
||||
if not info or not user_code:
|
||||
return jsonify({"ok": False, "error": "Missing data"}), 400
|
||||
|
||||
cfg = board.config
|
||||
# Store the token blob (t, uid, expire_time, access_token, refresh_token)
|
||||
cfg["tuya_token_info"] = {
|
||||
"t": info.get("t", 0),
|
||||
"uid": info.get("uid", ""),
|
||||
"expire_time": info.get("expire_time", 7776000),
|
||||
"access_token": info.get("access_token", ""),
|
||||
"refresh_token": info.get("refresh_token", ""),
|
||||
}
|
||||
cfg["tuya_user_code"] = user_code
|
||||
# terminal_id and endpoint are returned by Tuya in the login_result info dict
|
||||
cfg["tuya_terminal_id"] = info.get("terminal_id", "")
|
||||
cfg["tuya_endpoint"] = info.get("endpoint", "https://apigw.iotbing.com")
|
||||
board.config = cfg
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"ok": True, "redirect": url_for("tuya.gateway", board_id=board_id)})
|
||||
|
||||
|
||||
# ── Sync devices ──────────────────────────────────────────────────────────────
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/sync", methods=["POST"])
|
||||
@login_required
|
||||
def sync_devices(board_id: int):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = _get_gateway(board_id)
|
||||
drv = _get_driver(board)
|
||||
try:
|
||||
n = drv.sync_devices(board)
|
||||
db.session.commit()
|
||||
flash(f"✓ Synced {n} Tuya device(s).", "success")
|
||||
except Exception as exc:
|
||||
flash(f"Sync failed: {exc}", "danger")
|
||||
return redirect(url_for("tuya.gateway", board_id=board_id))
|
||||
|
||||
|
||||
# ── Device detail ─────────────────────────────────────────────────────────────
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/device/<device_id>")
|
||||
@login_required
|
||||
def device_detail(board_id: int, device_id: str):
|
||||
board = _get_gateway(board_id)
|
||||
device = TuyaDevice.query.filter_by(
|
||||
board_id=board_id, device_id=device_id
|
||||
).first_or_404()
|
||||
return render_template("tuya/device.html", board=board, device=device)
|
||||
|
||||
|
||||
# ── Toggle ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/device/<device_id>/dp/<dp_code>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_dp(board_id: int, device_id: str, dp_code: str):
|
||||
board = _get_gateway(board_id)
|
||||
device = TuyaDevice.query.filter_by(
|
||||
board_id=board_id, device_id=device_id
|
||||
).first_or_404()
|
||||
drv = _get_driver(board)
|
||||
ok = drv.toggle_dp(board, device_id, dp_code)
|
||||
|
||||
new_state = device.status.get(dp_code, False)
|
||||
|
||||
# Broadcast to all connected clients
|
||||
socketio.emit("tuya_update", {
|
||||
"board_id": board_id,
|
||||
"device_id": device_id,
|
||||
"dp_code": dp_code,
|
||||
"state": new_state,
|
||||
})
|
||||
|
||||
if request.headers.get("Accept") == "application/json" or \
|
||||
request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return jsonify({"ok": ok, "state": new_state})
|
||||
return redirect(url_for("tuya.device_detail", board_id=board_id, device_id=device_id))
|
||||
|
||||
|
||||
# ── Rename ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@tuya_bp.route("/<int:board_id>/device/<device_id>/rename", methods=["POST"])
|
||||
@login_required
|
||||
def rename_device(board_id: int, device_id: str):
|
||||
if not current_user.is_admin():
|
||||
abort(403)
|
||||
board = _get_gateway(board_id)
|
||||
device = TuyaDevice.query.filter_by(
|
||||
board_id=board_id, device_id=device_id
|
||||
).first_or_404()
|
||||
new_name = request.form.get("name", "").strip()
|
||||
if new_name:
|
||||
device.name = new_name
|
||||
db.session.commit()
|
||||
flash(f"Device renamed to '{new_name}'.", "success")
|
||||
return redirect(url_for("tuya.device_detail", board_id=board_id, device_id=device_id))
|
||||
@@ -257,11 +257,15 @@
|
||||
// relay type badge
|
||||
const typeText = opts.entityType === "relay" ? "relay"
|
||||
: opts.entityType === "sonoff" ? "sonoff"
|
||||
: opts.entityType === "tuya" ? "tuya"
|
||||
: "input";
|
||||
const badge = new Konva.Text({
|
||||
x: 36, y: 38,
|
||||
text: typeText, fontSize: 9, fontFamily: "system-ui, sans-serif",
|
||||
fill: opts.entityType === "relay" ? "#f0883e" : opts.entityType === "sonoff" ? "#58a6ff" : "#3fb950",
|
||||
fill: opts.entityType === "relay" ? "#f0883e"
|
||||
: opts.entityType === "sonoff" ? "#58a6ff"
|
||||
: opts.entityType === "tuya" ? "#20c997"
|
||||
: "#3fb950",
|
||||
name: "device-type",
|
||||
});
|
||||
|
||||
@@ -310,6 +314,8 @@
|
||||
.replace("{relayNum}", opts.entityNum);
|
||||
} else if (opts.entityType === "sonoff") {
|
||||
url = `/sonoff/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/ch/${opts.channel}/toggle`;
|
||||
} else if (opts.entityType === "tuya") {
|
||||
url = `/tuya/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/dp/${encodeURIComponent(opts.channel)}/toggle`;
|
||||
} else {
|
||||
return; // inputs are read-only
|
||||
}
|
||||
@@ -399,6 +405,14 @@
|
||||
isOn = entityInfo.isOn;
|
||||
onColor = entityInfo.kind === "light" ? "warning" : "success";
|
||||
offColor = "secondary";
|
||||
} else if (d.entityType === "tuya") {
|
||||
// Match by deviceId + channel (dp_code) stored in tuya_channels
|
||||
entityInfo = board.tuya_channels &&
|
||||
board.tuya_channels.find(tc => tc.deviceId === d.deviceId && tc.channel === d.channel);
|
||||
if (!entityInfo) return;
|
||||
isOn = entityInfo.isOn;
|
||||
onColor = entityInfo.kind === "light" ? "warning" : "success";
|
||||
offColor = "secondary";
|
||||
} else if (d.entityType === "relay") {
|
||||
entityInfo = board.relays.find(r => r.num === d.entityNum);
|
||||
if (!entityInfo) return;
|
||||
@@ -427,7 +441,7 @@
|
||||
boardName: board.name,
|
||||
icon: entityInfo.icon,
|
||||
stateColor: colorForState(isOn, onColor, offColor),
|
||||
isRelay: d.entityType === "relay" || d.entityType === "sonoff",
|
||||
isRelay: d.entityType === "relay" || d.entityType === "sonoff" || d.entityType === "tuya",
|
||||
});
|
||||
deviceLayer.add(group);
|
||||
});
|
||||
@@ -837,6 +851,13 @@
|
||||
hasAny = true;
|
||||
});
|
||||
}
|
||||
// ── Tuya sub-devices ─────────────────────────────────────────────
|
||||
if (board.tuya_channels && board.tuya_channels.length) {
|
||||
board.tuya_channels.forEach(tc => {
|
||||
grp.appendChild(makeTuyaChip(board, tc));
|
||||
hasAny = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAny) list.appendChild(grp);
|
||||
});
|
||||
@@ -928,6 +949,46 @@
|
||||
return chip;
|
||||
}
|
||||
|
||||
function makeTuyaChip(board, tc) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "lb-device-chip";
|
||||
chip.draggable = true;
|
||||
chip.dataset.boardId = board.id;
|
||||
chip.dataset.entityType = "tuya";
|
||||
chip.dataset.deviceId = tc.deviceId;
|
||||
chip.dataset.channel = tc.channel; // dp_code string
|
||||
chip.dataset.name = tc.name;
|
||||
chip.dataset.boardName = board.name;
|
||||
chip.dataset.isOn = tc.isOn;
|
||||
|
||||
const onColor = tc.kind === "light" ? "warning" : "success";
|
||||
const dotColor = colorForState(tc.isOn, onColor, "secondary");
|
||||
|
||||
chip.innerHTML = `
|
||||
<span class="chip-dot" style="background:${dotColor}"></span>
|
||||
<i class="bi ${escHtml(tc.icon)}" style="font-size:13px"></i>
|
||||
<span class="text-truncate">${escHtml(tc.name)}</span>
|
||||
<span class="ms-auto text-secondary" style="font-size:.7rem">${escHtml(tc.kind || 'switch')}</span>`;
|
||||
|
||||
chip.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/json", JSON.stringify({
|
||||
boardId: parseInt(board.id),
|
||||
entityType: "tuya",
|
||||
entityNum: null,
|
||||
deviceId: tc.deviceId,
|
||||
channel: tc.channel,
|
||||
name: tc.name,
|
||||
boardName: board.name,
|
||||
onColor,
|
||||
offColor: "secondary",
|
||||
isOn: tc.isOn,
|
||||
}));
|
||||
});
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
// ── canvas drop target ────────────────────────────────────────────────────
|
||||
const ghost = document.getElementById("lb-drag-ghost");
|
||||
|
||||
@@ -979,7 +1040,7 @@
|
||||
name: dragged.name,
|
||||
boardName: dragged.boardName,
|
||||
stateColor,
|
||||
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff",
|
||||
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff" || dragged.entityType === "tuya",
|
||||
});
|
||||
|
||||
deviceLayer.add(group);
|
||||
@@ -1020,6 +1081,25 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Tuya devices emit a "tuya_update" event
|
||||
socket.on("tuya_update", (data) => {
|
||||
const bid = data.board_id;
|
||||
|
||||
deviceLayer.find(".device-group").forEach(group => {
|
||||
if (group.attrs.boardId !== bid) return;
|
||||
if (group.attrs.entityType !== "tuya") return;
|
||||
if (group.attrs.deviceId !== data.device_id) return;
|
||||
if (group.attrs.channel !== data.dp_code) return;
|
||||
|
||||
const board = CFG.boards.find(b => b.id === bid);
|
||||
const tc = board && board.tuya_channels &&
|
||||
board.tuya_channels.find(t =>
|
||||
t.deviceId === data.device_id && t.channel === data.dp_code);
|
||||
const onColor = tc ? (tc.kind === "light" ? "warning" : "success") : "success";
|
||||
updateDeviceState(group, data.state, onColor, "secondary");
|
||||
});
|
||||
});
|
||||
|
||||
// Sonoff sub-devices emit a separate "sonoff_update" event
|
||||
socket.on("sonoff_update", (data) => {
|
||||
const bid = data.board_id;
|
||||
|
||||
@@ -2,99 +2,340 @@
|
||||
{% block title %}Add Board – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item active">Add Board</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:640px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-plus-circle me-1 text-primary"></i> Add New Board
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Local Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Server Room Board" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Board Type</label>
|
||||
<select name="board_type" class="form-select" id="board_type_select" onchange="updateDefaults(this.value)">
|
||||
{% for value, label in board_types %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-secondary" id="driver_desc"></div>
|
||||
</div>
|
||||
{# ── Hardware board fields (hidden for Sonoff gateway) ── #}
|
||||
<div id="hw_fields">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label">IP Address / Hostname</label>
|
||||
<input type="text" name="host" id="host_input" class="form-control font-monospace" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Relays</label>
|
||||
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="0" max="32" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Inputs</label>
|
||||
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# ── Step indicator ──────────────────────────────────────────────────────── #}
|
||||
<div class="d-flex align-items-center mb-4" id="step-indicator" style="max-width:420px">
|
||||
<div class="step-bubble active" id="bubble-1">1</div>
|
||||
<span class="small fw-semibold ms-2 me-3" id="lbl-1">Choose Type</span>
|
||||
<div class="step-line" id="step-line"></div>
|
||||
<div class="step-bubble ms-3 me-2" id="bubble-2">2</div>
|
||||
<span class="small text-muted" id="lbl-2">Configure</span>
|
||||
</div>
|
||||
|
||||
{# ── Sonoff gateway info ── #}
|
||||
<div id="sonoff_info" class="d-none mb-4">
|
||||
<div class="alert alert-info d-flex gap-3 align-items-start py-3">
|
||||
<i class="bi bi-cloud-lightning-fill fs-4 text-warning mt-1"></i>
|
||||
{# ══════════════════════════════════════════════════════════
|
||||
STEP 1 – pick a board / integration type
|
||||
══════════════════════════════════════════════════════════ #}
|
||||
<div id="step1">
|
||||
<h5 class="fw-bold mb-1">What are you adding?</h5>
|
||||
<p class="text-muted small mb-4">Choose the hardware board or cloud service you want to connect.</p>
|
||||
|
||||
<p class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.07em">
|
||||
<i class="bi bi-cloud me-1"></i>Cloud Gateways
|
||||
</p>
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
<div class="col-12 col-sm-6">
|
||||
<button type="button" class="type-card w-100 text-start" data-type="sonoff_ewelink">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="type-icon" style="background:rgba(255,210,0,.12);color:#ffd200">
|
||||
<i class="bi bi-cloud-lightning-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Sonoff eWeLink Gateway</strong><br>
|
||||
<span class="small">
|
||||
After adding, you'll be redirected to set up your eWeLink account credentials.
|
||||
All your Sonoff devices will be discovered automatically from the cloud.
|
||||
LAN control is used when devices are reachable on the local network.
|
||||
</span>
|
||||
<div class="fw-semibold">Sonoff eWeLink</div>
|
||||
<div class="small text-muted mt-1">Control Sonoff devices via the eWeLink cloud.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
|
||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-sm-6">
|
||||
<button type="button" class="type-card w-100 text-start" data-type="tuya_cloud">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="type-icon" style="background:rgba(32,201,151,.12);color:#20c997">
|
||||
<i class="bi bi-cloud-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">Tuya Cloud</div>
|
||||
<div class="small text-muted mt-1">Control Tuya / Smart Life devices via QR login.</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.07em">
|
||||
<i class="bi bi-cpu me-1"></i>Hardware Boards
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
{% for d in drivers %}
|
||||
{% if d.DRIVER_ID not in ("sonoff_ewelink", "tuya_cloud") %}
|
||||
<div class="col-12 col-sm-6 col-lg-4">
|
||||
<button type="button" class="type-card w-100 text-start" data-type="{{ d.DRIVER_ID }}">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="type-icon" style="background:rgba(88,166,255,.12);color:#58a6ff">
|
||||
<i class="bi bi-cpu-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ d.DRIVER_ID | replace('_',' ') | title }}</div>
|
||||
<div class="small text-muted mt-1">{{ d.DESCRIPTION }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# ══════════════════════════════════════════════════════════
|
||||
STEP 2 – configuration form
|
||||
══════════════════════════════════════════════════════════ #}
|
||||
<div id="step2" class="d-none" style="max-width:560px">
|
||||
|
||||
<button type="button" class="btn btn-link btn-sm text-muted ps-0 mb-3" id="btn-back">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</button>
|
||||
|
||||
{# Selected type badge #}
|
||||
<div class="d-flex align-items-center gap-3 mb-4">
|
||||
<div class="type-icon-sm" id="s2-icon-wrap"></div>
|
||||
<div>
|
||||
<div class="fw-bold fs-6" id="s2-type-label"></div>
|
||||
<div class="small text-muted" id="s2-type-desc"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="add-form">
|
||||
<input type="hidden" name="board_type" id="f-board-type">
|
||||
|
||||
{# Name – always shown #}
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Board Name</label>
|
||||
<input type="text" name="name" id="f-name" class="form-control"
|
||||
placeholder="e.g. Living Room Gateway" required autofocus>
|
||||
<div class="form-text text-muted">A friendly name to identify this board.</div>
|
||||
</div>
|
||||
|
||||
{# ── Gateway info box (Sonoff / Tuya) ── #}
|
||||
<div id="f-gw-section" class="d-none mb-4">
|
||||
<div class="alert py-3 d-flex gap-3 align-items-start" id="f-gw-alert">
|
||||
<span id="f-gw-icon" class="fs-4 mt-1"></span>
|
||||
<div id="f-gw-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Hardware-only fields ── #}
|
||||
<div id="f-hw-section" class="d-none">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">IP Address / Hostname</label>
|
||||
<input type="text" name="host" id="f-host" class="form-control font-monospace"
|
||||
placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="80" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Relay Outputs</label>
|
||||
<input type="number" name="num_relays" id="f-relays" class="form-control"
|
||||
value="4" min="0" max="32">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Digital Inputs</label>
|
||||
<input type="number" name="num_inputs" id="f-inputs" class="form-control"
|
||||
value="4" min="0" max="32">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-lg me-1"></i>Add Board
|
||||
</button>
|
||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.type-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: .75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
color: inherit;
|
||||
}
|
||||
.type-card:hover, .type-card:focus {
|
||||
border-color: #58a6ff;
|
||||
background: rgba(88,166,255,.06);
|
||||
box-shadow: 0 0 0 3px rgba(88,166,255,.12);
|
||||
outline: none;
|
||||
}
|
||||
.type-icon {
|
||||
width: 44px; height: 44px; border-radius: .55rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; flex-shrink: 0;
|
||||
}
|
||||
.type-icon-sm {
|
||||
width: 38px; height: 38px; border-radius: .45rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.25rem; flex-shrink: 0;
|
||||
}
|
||||
.step-bubble {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: #30363d; color: #8b949e;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .8rem; font-weight: 700; flex-shrink: 0;
|
||||
transition: background .2s, color .2s;
|
||||
}
|
||||
.step-bubble.active { background: #1f6feb; color: #fff; }
|
||||
.step-bubble.done { background: #238636; color: #fff; }
|
||||
.step-line {
|
||||
flex: 1; height: 2px; background: #30363d;
|
||||
min-width: 32px; max-width: 60px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.step-line.done { background: #238636; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const driverDefaults = {
|
||||
{% for d in drivers %}
|
||||
"{{ d.DRIVER_ID }}": { relays: {{ d.DEFAULT_NUM_RELAYS }}, inputs: {{ d.DEFAULT_NUM_INPUTS }}, desc: "{{ d.DESCRIPTION }}" },
|
||||
{% endfor %}
|
||||
};
|
||||
function updateDefaults(type) {
|
||||
const d = driverDefaults[type] || { relays: 4, inputs: 4, desc: "" };
|
||||
document.getElementById("num_relays").value = d.relays;
|
||||
document.getElementById("num_inputs").value = d.inputs;
|
||||
document.getElementById("driver_desc").textContent = d.desc;
|
||||
(function () {
|
||||
const DRIVERS = {
|
||||
{% for d in drivers %}
|
||||
"{{ d.DRIVER_ID }}": {
|
||||
relays: {{ d.DEFAULT_NUM_RELAYS }},
|
||||
inputs: {{ d.DEFAULT_NUM_INPUTS }},
|
||||
desc: "{{ d.DESCRIPTION | replace('"', '\\"') }}",
|
||||
},
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
const isGateway = type === "sonoff_ewelink";
|
||||
document.getElementById("hw_fields").classList.toggle("d-none", isGateway);
|
||||
document.getElementById("sonoff_info").classList.toggle("d-none", !isGateway);
|
||||
// host is not required for gateway
|
||||
document.getElementById("host_input").required = !isGateway;
|
||||
}
|
||||
// init on page load
|
||||
updateDefaults(document.getElementById("board_type_select").value);
|
||||
const GW_META = {
|
||||
sonoff_ewelink: {
|
||||
label: "Sonoff eWeLink",
|
||||
iconHtml: '<i class="bi bi-cloud-lightning-fill"></i>',
|
||||
iconBg: "rgba(255,210,0,.15)",
|
||||
iconColor: "#ffd200",
|
||||
alertCls: "alert-warning",
|
||||
alertIcon: '<i class="bi bi-cloud-lightning-fill text-warning"></i>',
|
||||
alertBody: '<strong>Sonoff eWeLink Gateway</strong><br>'
|
||||
+ '<span class="small">After adding, you\'ll enter your eWeLink '
|
||||
+ 'account credentials to discover and control all your Sonoff devices '
|
||||
+ 'automatically.</span>',
|
||||
},
|
||||
tuya_cloud: {
|
||||
label: "Tuya Cloud",
|
||||
iconHtml: '<i class="bi bi-cloud-fill"></i>',
|
||||
iconBg: "rgba(32,201,151,.15)",
|
||||
iconColor: "#20c997",
|
||||
alertCls: "alert-info",
|
||||
alertIcon: '<i class="bi bi-qr-code text-info"></i>',
|
||||
alertBody: '<strong>Tuya / Smart Life Cloud Gateway</strong><br>'
|
||||
+ '<span class="small">After adding, you\'ll scan a QR code with '
|
||||
+ 'the <strong>Smart Life</strong> or <strong>Tuya Smart</strong> app '
|
||||
+ 'to link your account and discover all your Tuya devices automatically.</span>',
|
||||
},
|
||||
};
|
||||
|
||||
const GATEWAY_TYPES = Object.keys(GW_META);
|
||||
|
||||
// DOM
|
||||
const step1 = document.getElementById("step1");
|
||||
const step2 = document.getElementById("step2");
|
||||
const btnBack = document.getElementById("btn-back");
|
||||
const bubble1 = document.getElementById("bubble-1");
|
||||
const bubble2 = document.getElementById("bubble-2");
|
||||
const stepLine = document.getElementById("step-line");
|
||||
const lbl1 = document.getElementById("lbl-1");
|
||||
const lbl2 = document.getElementById("lbl-2");
|
||||
|
||||
const fType = document.getElementById("f-board-type");
|
||||
const fName = document.getElementById("f-name");
|
||||
const fHost = document.getElementById("f-host");
|
||||
const fRelays = document.getElementById("f-relays");
|
||||
const fInputs = document.getElementById("f-inputs");
|
||||
const hwSec = document.getElementById("f-hw-section");
|
||||
const gwSec = document.getElementById("f-gw-section");
|
||||
const gwAlert = document.getElementById("f-gw-alert");
|
||||
const gwIcon = document.getElementById("f-gw-icon");
|
||||
const gwText = document.getElementById("f-gw-text");
|
||||
const s2Icon = document.getElementById("s2-icon-wrap");
|
||||
const s2Label = document.getElementById("s2-type-label");
|
||||
const s2Desc = document.getElementById("s2-type-desc");
|
||||
|
||||
// Card clicks
|
||||
document.querySelectorAll(".type-card").forEach(function (card) {
|
||||
card.addEventListener("click", function () { goStep2(card.dataset.type); });
|
||||
});
|
||||
|
||||
// Back
|
||||
btnBack.addEventListener("click", function () {
|
||||
step2.classList.add("d-none");
|
||||
step1.classList.remove("d-none");
|
||||
bubble1.classList.replace("done", "active");
|
||||
bubble2.classList.remove("active");
|
||||
stepLine.classList.remove("done");
|
||||
lbl1.classList.remove("text-success");
|
||||
lbl2.classList.add("text-muted");
|
||||
lbl2.classList.remove("fw-semibold");
|
||||
});
|
||||
|
||||
function goStep2(type) {
|
||||
const drv = DRIVERS[type] || { relays: 4, inputs: 4, desc: "" };
|
||||
const meta = GW_META[type] || null;
|
||||
const isGw = GATEWAY_TYPES.includes(type);
|
||||
|
||||
fType.value = type;
|
||||
fRelays.value = drv.relays;
|
||||
fInputs.value = drv.inputs;
|
||||
fName.value = "";
|
||||
|
||||
// Header icon + label
|
||||
if (meta) {
|
||||
s2Icon.innerHTML = meta.iconHtml;
|
||||
s2Icon.style.background = meta.iconBg;
|
||||
s2Icon.style.color = meta.iconColor;
|
||||
s2Label.textContent = meta.label;
|
||||
} else {
|
||||
s2Icon.innerHTML = '<i class="bi bi-cpu-fill"></i>';
|
||||
s2Icon.style.background = "rgba(88,166,255,.15)";
|
||||
s2Icon.style.color = "#58a6ff";
|
||||
s2Label.textContent = type.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
s2Desc.textContent = drv.desc;
|
||||
|
||||
// Section visibility
|
||||
if (isGw) {
|
||||
hwSec.classList.add("d-none");
|
||||
fHost.required = false;
|
||||
gwSec.classList.remove("d-none");
|
||||
gwAlert.className = "alert py-3 d-flex gap-3 align-items-start " + meta.alertCls;
|
||||
gwIcon.innerHTML = meta.alertIcon;
|
||||
gwText.innerHTML = meta.alertBody;
|
||||
} else {
|
||||
hwSec.classList.remove("d-none");
|
||||
fHost.required = true;
|
||||
gwSec.classList.add("d-none");
|
||||
}
|
||||
|
||||
// Step indicator
|
||||
step1.classList.add("d-none");
|
||||
step2.classList.remove("d-none");
|
||||
bubble1.classList.remove("active");
|
||||
bubble1.classList.add("done");
|
||||
bubble2.classList.add("active");
|
||||
stepLine.classList.add("done");
|
||||
lbl1.classList.add("text-success");
|
||||
lbl2.classList.remove("text-muted");
|
||||
lbl2.classList.add("fw-semibold");
|
||||
|
||||
fName.focus();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
215
app/templates/tuya/auth_settings.html
Normal file
215
app/templates/tuya/auth_settings.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ board.name }} – Tuya Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4" style="max-width:560px">
|
||||
<a href="{{ url_for('tuya.gateway', board_id=board.id) }}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Gateway
|
||||
</a>
|
||||
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-qr-code me-2 text-info"></i>Tuya / Smart Life Account
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} py-2 alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<p class="text-muted small mb-4">
|
||||
Link your <strong>Tuya / Smart Life</strong> account by scanning a QR code with
|
||||
the mobile app. No tokens or passwords are stored in plain text – only the
|
||||
access/refresh token pair returned by SSO is persisted.
|
||||
</p>
|
||||
|
||||
{# Current status #}
|
||||
{% if board.config.get('tuya_token_info') %}
|
||||
<div class="alert alert-success py-2 d-flex align-items-center gap-2" id="linked-status">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>
|
||||
Account linked.
|
||||
<small class="opacity-75">(User code: {{ board.config.get('tuya_user_code', '?') }})</small>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary py-2 d-flex align-items-center gap-2" id="linked-status">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
<span>No account linked yet.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# QR section #}
|
||||
<div id="qr-section">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="input-user-code" class="form-label fw-semibold">Your Tuya User Code</label>
|
||||
<input type="text" id="input-user-code" class="form-control"
|
||||
placeholder="e.g. myname123"
|
||||
value="{{ board.config.get('tuya_user_code', '') }}">
|
||||
<div class="form-text">
|
||||
Choose any short identifier for this installation (letters and numbers, no spaces).
|
||||
You will need to remember it if you ever re-link the account.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-start-qr" class="btn btn-info mb-3">
|
||||
<i class="bi bi-qr-code me-1"></i>Generate QR Code
|
||||
</button>
|
||||
|
||||
<div id="qr-area" class="text-center d-none">
|
||||
<div id="qr-canvas" class="d-inline-block p-2 bg-white rounded mb-3"></div>
|
||||
<p class="text-muted small mb-1" id="qr-instruction">
|
||||
Open the <strong>Smart Life</strong> (or Tuya Smart) app → tap the
|
||||
<i class="bi bi-qr-code-scan"></i> scan icon → scan this QR code.
|
||||
</p>
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 mt-2">
|
||||
<div class="spinner-border spinner-border-sm text-info" role="status" id="qr-spinner">
|
||||
<span class="visually-hidden">Waiting…</span>
|
||||
</div>
|
||||
<span class="text-muted small" id="qr-poll-msg">Waiting for scan…</span>
|
||||
</div>
|
||||
<button id="btn-cancel-qr" class="btn btn-outline-secondary btn-sm mt-3">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- QR code renderer (MIT, CDN) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const BOARD_ID = {{ board.id }};
|
||||
const QR_URL = "{{ url_for('tuya.generate_qr', board_id=board.id) }}";
|
||||
const POLL_URL = "{{ url_for('tuya.poll_login', board_id=board.id) }}";
|
||||
const SAVE_URL = "{{ url_for('tuya.save_auth', board_id=board.id) }}";
|
||||
const GW_URL = "{{ url_for('tuya.gateway', board_id=board.id) }}";
|
||||
|
||||
let pollTimer = null;
|
||||
let userCode = null;
|
||||
let qrToken = null;
|
||||
|
||||
const btnStart = document.getElementById('btn-start-qr');
|
||||
const btnCancel = document.getElementById('btn-cancel-qr');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const qrCanvas = document.getElementById('qr-canvas');
|
||||
const spinner = document.getElementById('qr-spinner');
|
||||
const pollMsg = document.getElementById('qr-poll-msg');
|
||||
const statusDiv = document.getElementById('linked-status');
|
||||
const inputCode = document.getElementById('input-user-code');
|
||||
|
||||
btnStart.addEventListener('click', startQr);
|
||||
btnCancel.addEventListener('click', cancelQr);
|
||||
|
||||
function startQr() {
|
||||
const code = inputCode.value.trim();
|
||||
if (!code) {
|
||||
inputCode.focus();
|
||||
inputCode.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
inputCode.classList.remove('is-invalid');
|
||||
|
||||
btnStart.disabled = true;
|
||||
btnStart.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating…';
|
||||
|
||||
fetch(QR_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_code: code }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
if (!data.ok) { showError(data.error); btnStart.disabled = false; btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code'; return; }
|
||||
|
||||
userCode = data.user_code;
|
||||
qrToken = data.qr_token;
|
||||
|
||||
// Render QR code into canvas div
|
||||
qrCanvas.innerHTML = '';
|
||||
QRCode.toCanvas(document.createElement('canvas'), data.qr_data, { width: 220 }, function(err, canvas) {
|
||||
if (err) { showError('QR render failed: ' + err); return; }
|
||||
qrCanvas.appendChild(canvas);
|
||||
});
|
||||
|
||||
qrArea.classList.remove('d-none');
|
||||
btnStart.classList.add('d-none');
|
||||
inputCode.disabled = true;
|
||||
pollMsg.textContent = 'Waiting for scan…';
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
// Start polling every 3 s
|
||||
pollTimer = setInterval(pollLogin, 3000);
|
||||
})
|
||||
.catch(function(err) { showError(err); btnStart.disabled = false; btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code'; });
|
||||
}
|
||||
|
||||
function pollLogin() {
|
||||
const url = POLL_URL + '?token=' + encodeURIComponent(qrToken) + '&user_code=' + encodeURIComponent(userCode);
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
clearInterval(pollTimer);
|
||||
pollMsg.textContent = '✓ Scanned! Saving…';
|
||||
spinner.classList.add('d-none');
|
||||
saveTokens(data.info);
|
||||
} else if (data.error) {
|
||||
clearInterval(pollTimer);
|
||||
pollMsg.textContent = 'Error: ' + data.error;
|
||||
spinner.classList.add('d-none');
|
||||
}
|
||||
// else data.pending: keep waiting
|
||||
})
|
||||
.catch(function() { /* network hiccup – keep polling */ });
|
||||
}
|
||||
|
||||
function saveTokens(info) {
|
||||
fetch(SAVE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ info: info, user_code: userCode }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
pollMsg.textContent = '✓ Account linked successfully! Redirecting…';
|
||||
setTimeout(function() { window.location.href = data.redirect || GW_URL; }, 1500);
|
||||
} else {
|
||||
showError(data.error || 'Save failed');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { showError(err); });
|
||||
}
|
||||
|
||||
function cancelQr() {
|
||||
clearInterval(pollTimer);
|
||||
qrArea.classList.add('d-none');
|
||||
btnStart.classList.remove('d-none');
|
||||
btnStart.disabled = false;
|
||||
btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code';
|
||||
qrCanvas.innerHTML = '';
|
||||
inputCode.disabled = false;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'alert alert-danger py-2 mt-2';
|
||||
el.textContent = msg;
|
||||
qrCanvas.after(el);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
165
app/templates/tuya/device.html
Normal file
165
app/templates/tuya/device.html
Normal file
@@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ device.name }} – Tuya Device{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4" style="max-width:640px">
|
||||
|
||||
{# ── Breadcrumb ──────────────────────────────────────────────────── #}
|
||||
<nav aria-label="breadcrumb" class="small mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('tuya.gateway', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ device.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# ── Device card ─────────────────────────────────────────────────── #}
|
||||
{% set icon_cls = {
|
||||
'switch': 'bi-toggles',
|
||||
'light': 'bi-lightbulb-fill',
|
||||
'fan': 'bi-fan',
|
||||
'sensor': 'bi-thermometer-half',
|
||||
'cover': 'bi-door-open',
|
||||
}.get(device.kind, 'bi-plug') %}
|
||||
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi {{ icon_cls }} fs-5 text-info"></i>
|
||||
<span class="fw-bold">{{ device.name }}</span>
|
||||
</div>
|
||||
{% if device.is_online %}
|
||||
<span class="badge bg-success">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-5 text-muted">Device ID</dt>
|
||||
<dd class="col-7 font-monospace text-break">{{ device.device_id }}</dd>
|
||||
<dt class="col-5 text-muted">Category</dt>
|
||||
<dd class="col-7">{{ device.category }} ({{ device.kind }})</dd>
|
||||
{% if device.product_name %}
|
||||
<dt class="col-5 text-muted">Product</dt>
|
||||
<dd class="col-7">{{ device.product_name }}</dd>
|
||||
{% endif %}
|
||||
{% if device.last_seen %}
|
||||
<dt class="col-5 text-muted">Last seen</dt>
|
||||
<dd class="col-7">{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Controls ────────────────────────────────────────────────────── #}
|
||||
{% if device.switch_dps %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header py-2"><strong>Controls</strong></div>
|
||||
<div class="card-body d-flex flex-wrap gap-3">
|
||||
{% for dp in device.switch_dps %}
|
||||
{% set ch_on = device.status.get(dp, False) %}
|
||||
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-3 py-2"
|
||||
id="ctrl-dp-{{ dp }}">
|
||||
<span class="text-muted small">
|
||||
{% if device.num_channels == 1 %}Power{% else %}CH{{ loop.index }}{% endif %}
|
||||
<span class="font-monospace opacity-50">({{ dp }})</span>
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm px-3 tuya-dp-btn {{ 'btn-info' if ch_on else 'btn-outline-info' }}"
|
||||
data-url="{{ url_for('tuya.toggle_dp', board_id=board.id, device_id=device.device_id, dp_code=dp) }}"
|
||||
data-dp="{{ dp }}"
|
||||
>{{ 'ON' if ch_on else 'OFF' }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Full status ─────────────────────────────────────────────────── #}
|
||||
{% if device.status %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header py-2 d-flex align-items-center justify-content-between">
|
||||
<strong>Full Status</strong>
|
||||
<small class="text-muted">All data points</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-sm table-striped mb-0">
|
||||
<thead><tr><th>DP Code</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
{% for k, v in device.status.items() %}
|
||||
{% set fv = v | tuya_dp(k, device.status) %}
|
||||
<tr>
|
||||
<td class="font-monospace text-muted">{{ k }}</td>
|
||||
<td>
|
||||
{% if fv is sameas true %}
|
||||
<span class="badge bg-success">true</span>
|
||||
{% elif fv is sameas false %}
|
||||
<span class="badge bg-secondary">false</span>
|
||||
{% else %}
|
||||
{{ fv }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Rename ──────────────────────────────────────────────────────── #}
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header py-2"><strong>Rename Device</strong></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('tuya.rename_device', board_id=board.id, device_id=device.device_id) }}"
|
||||
class="d-flex gap-2">
|
||||
<input type="text" name="name" class="form-control form-control-sm"
|
||||
value="{{ device.name }}" required style="max-width:300px">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>Rename
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelectorAll('.tuya-dp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const url = btn.dataset.url;
|
||||
const dp = btn.dataset.dp;
|
||||
btn.disabled = true;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
const wrap = document.getElementById('ctrl-dp-' + dp);
|
||||
if (!wrap) return;
|
||||
const b = wrap.querySelector('.tuya-dp-btn');
|
||||
if (!b) return;
|
||||
b.disabled = false;
|
||||
if (data.state) {
|
||||
b.textContent = 'ON';
|
||||
b.classList.replace('btn-outline-info', 'btn-info');
|
||||
} else {
|
||||
b.textContent = 'OFF';
|
||||
b.classList.replace('btn-info', 'btn-outline-info');
|
||||
}
|
||||
})
|
||||
.catch(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}{% for cat, msg in messages %}{# consumed by parent template #}{% endfor %}{% endif %}
|
||||
{% endwith %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
214
app/templates/tuya/gateway.html
Normal file
214
app/templates/tuya/gateway.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ board.name }} – Tuya Cloud Gateway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
{# ── Page header ──────────────────────────────────────────────────── #}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h4 class="mb-0 fw-bold">
|
||||
<i class="bi bi-cloud-fill text-info me-2"></i>{{ board.name }}
|
||||
</h4>
|
||||
<small class="text-muted">Tuya Cloud Gateway
|
||||
{% if board.is_online %}
|
||||
<span class="badge bg-success ms-2">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">Offline</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{% if current_user.is_admin() %}
|
||||
<form method="post" action="{{ url_for('tuya.sync_devices', board_id=board.id) }}">
|
||||
<button class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync Devices
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('tuya.auth_settings', board_id=board.id) }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-key me-1"></i>Tuya Settings
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Flash messages ───────────────────────────────────────────────── #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show py-2" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{# ── No token warning ─────────────────────────────────────────────── #}
|
||||
{% if not has_token %}
|
||||
<div class="alert alert-warning d-flex align-items-center gap-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
|
||||
<div>
|
||||
<strong>Tuya account not linked.</strong>
|
||||
<a href="{{ url_for('tuya.auth_settings', board_id=board.id) }}" class="alert-link">
|
||||
Scan a QR code with the Smart Life app
|
||||
</a> to discover and control devices.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Device grid ──────────────────────────────────────────────────── #}
|
||||
{% if devices %}
|
||||
<div class="row g-3">
|
||||
{% for dev in devices %}
|
||||
{% set icon_cls = {
|
||||
'switch': 'bi-toggles',
|
||||
'light': 'bi-lightbulb-fill',
|
||||
'fan': 'bi-fan',
|
||||
'sensor': 'bi-thermometer-half',
|
||||
'cover': 'bi-door-open',
|
||||
}.get(dev.kind, 'bi-plug') %}
|
||||
<div class="col-12 col-md-6 col-xl-4" id="tuya-card-{{ dev.device_id }}">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-header d-flex align-items-center justify-content-between py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi {{ icon_cls }} text-info"></i>
|
||||
<span class="fw-semibold text-truncate" style="max-width:160px"
|
||||
title="{{ dev.name }}">{{ dev.name }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if dev.is_online %}
|
||||
<span class="badge bg-success">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Offline</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('tuya.device_detail', board_id=board.id, device_id=dev.device_id) }}"
|
||||
class="btn btn-outline-secondary btn-sm px-2 py-0">
|
||||
<i class="bi bi-sliders"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="text-muted small mb-2">
|
||||
{{ dev.kind | capitalize }}
|
||||
{% if dev.product_name %}
|
||||
– {{ dev.product_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Switch / light / fan toggles ─────────────────────────── #}
|
||||
{% if dev.kind in ('switch', 'light', 'fan') and dev.switch_dps %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for dp in dev.switch_dps %}
|
||||
{% set ch_idx = loop.index0 %}
|
||||
{% set ch_on = dev.status.get(dp, False) %}
|
||||
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-2 py-1"
|
||||
id="tuya-dp-{{ dev.device_id }}-{{ dp }}">
|
||||
<span class="small text-muted">
|
||||
{% if dev.num_channels == 1 %}Power{% else %}CH{{ loop.index }}{% endif %}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm px-2 py-0 tuya-dp-btn {{ 'btn-info' if ch_on else 'btn-outline-info' }}"
|
||||
data-url="{{ url_for('tuya.toggle_dp', board_id=board.id, device_id=dev.device_id, dp_code=dp) }}"
|
||||
data-device="{{ dev.device_id }}" data-dp="{{ dp }}"
|
||||
title="Toggle {{ dp }}"
|
||||
>{{ 'ON' if ch_on else 'OFF' }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ── Sensor – show formatted live readings ────────────────── #}
|
||||
{% elif dev.kind == 'sensor' %}
|
||||
{# Hide config/threshold keys that clutter the card #}
|
||||
{% set _skip_suffixes = ['_set', '_alarm', '_convert', '_mode'] %}
|
||||
<dl class="row mb-0 small">
|
||||
{% for k, v in dev.status.items() %}
|
||||
{% set _hide = namespace(val=false) %}
|
||||
{% for sfx in _skip_suffixes %}
|
||||
{% if k.endswith(sfx) %}{% set _hide.val = true %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if not _hide.val %}
|
||||
{% set fv = v | tuya_dp(k, dev.status) %}
|
||||
<dt class="col-6 text-muted text-truncate" title="{{ k }}">{{ k }}</dt>
|
||||
<dd class="col-6 mb-1">
|
||||
{% if fv is sameas true %}<span class="badge bg-success">on</span>
|
||||
{% elif fv is sameas false %}<span class="badge bg-secondary">off</span>
|
||||
{% else %}{{ fv }}{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-cloud-slash fs-1 d-block mb-3 opacity-50"></i>
|
||||
<p>No Tuya devices found.</p>
|
||||
{% if has_token %}
|
||||
<form method="post" action="{{ url_for('tuya.sync_devices', board_id=board.id) }}" class="d-inline">
|
||||
<button class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync now
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
/* ── AJAX toggles ─────────────────────────────────────────────────────────── */
|
||||
document.querySelectorAll('.tuya-dp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const url = btn.dataset.url;
|
||||
const device = btn.dataset.device;
|
||||
const dp = btn.dataset.dp;
|
||||
btn.disabled = true;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
updateDpButton(device, dp, data.state);
|
||||
})
|
||||
.catch(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
function updateDpButton(deviceId, dp, state) {
|
||||
const wrap = document.getElementById('tuya-dp-' + deviceId + '-' + dp);
|
||||
if (!wrap) return;
|
||||
const btn = wrap.querySelector('.tuya-dp-btn');
|
||||
if (!btn) return;
|
||||
btn.disabled = false;
|
||||
if (state) {
|
||||
btn.textContent = 'ON';
|
||||
btn.classList.replace('btn-outline-info', 'btn-info');
|
||||
} else {
|
||||
btn.textContent = 'OFF';
|
||||
btn.classList.replace('btn-info', 'btn-outline-info');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Socket.IO live updates ───────────────────────────────────────────────── */
|
||||
if (typeof io !== 'undefined') {
|
||||
const socket = io();
|
||||
socket.on('tuya_update', function(data) {
|
||||
if (data.board_id !== {{ board.id }}) return;
|
||||
updateDpButton(data.device_id, data.dp_code, data.state);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -12,3 +12,5 @@ WTForms==3.1.2
|
||||
SQLAlchemy==2.0.31
|
||||
greenlet>=3.1.1
|
||||
pycryptodome==3.23.0
|
||||
cryptography>=42.0.0
|
||||
tuya-device-sharing-sdk>=0.2.9
|
||||
|
||||
Reference in New Issue
Block a user