diff --git a/Boiler-Tuya-Energy.md b/Boiler-Tuya-Energy.md new file mode 100644 index 0000000..a9ab866 --- /dev/null +++ b/Boiler-Tuya-Energy.md @@ -0,0 +1,9 @@ +# Boiler Tuya Energy — casa Dublin + +Projeto **dedicado** (nao e o Tapo R630). + +- Pasta Obsidian: `Boiler-Tuya-Energy/` +- App: https://boiler.myvexx.com/ +- R630 (Tapo, separado): https://r630.myvexx.com/ +- Live: BIG CT120 `/opt/boiler-energy` :8788 +- Token Cloudflare zona myvexx: `Infra-Network/SECRETS_CLOUDFLARE_MYVEXX.md` \ No newline at end of file diff --git a/Boiler-Tuya-Energy/.gitignore b/Boiler-Tuya-Energy/.gitignore new file mode 100644 index 0000000..6f13bf7 --- /dev/null +++ b/Boiler-Tuya-Energy/.gitignore @@ -0,0 +1,5 @@ +data/ +*.db +boiler.env +site-packages/ +__pycache__/ diff --git a/Boiler-Tuya-Energy/Dockerfile b/Boiler-Tuya-Energy/Dockerfile new file mode 100644 index 0000000..8c72e85 --- /dev/null +++ b/Boiler-Tuya-Energy/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install --no-cache-dir aiohttp==3.12.15 +COPY tuya_boiler_server.py /app/tuya_boiler_server.py +RUN useradd --system --uid 10001 boiler && mkdir /data && chown boiler:boiler /data +USER boiler +EXPOSE 8788 +CMD ["python", "/app/tuya_boiler_server.py"] diff --git a/Boiler-Tuya-Energy/PROJECT.md b/Boiler-Tuya-Energy/PROJECT.md new file mode 100644 index 0000000..547e6d8 --- /dev/null +++ b/Boiler-Tuya-Energy/PROJECT.md @@ -0,0 +1,46 @@ +# PROJECT — Boiler Tuya Energy (Dublin) + +Dashboard **somente leitura** do interruptor Tuya do boiler. Consome logs ON/OFF da nuvem Tuya, grava SQLite e estima kWh/€. **Não mede watts** (o hardware CB01-SBL não tem sensor). **Não liga/desliga** o relé. + +**Atualizado:** 2026-09-08 +**URL:** https://boiler.myvexx.com/ +**Fallback:** https://r630.myvexx.com/boiler/ +**Separado do R630:** Tapo P110 continua em https://r630.myvexx.com/ (VM500 :8787) + +## O que o sistema faz + +- Sincroniza `switch_1` (status + logs) da Tuya Cloud EU +- Guarda eventos em SQLite (`switch_events`) +- Relatório por intervalo de datas (kWh e €) +- Export CSV +- Tarifa Electric Ireland ValueReward 4%: **€0,3652/kWh** (fatura 23 Jul 2026, 0,3490 ex. IVA × 0,96 × 1,09) +- Potência assumida da resistência: **2,3 kW** +- Poll a cada 5 minutos + +## Fórmula + +`kWh = 2,3 kW × (minutos ON / 60)` +`€ = kWh × 0,3652` +Termóstato interno do boiler pode reduzir o consumo real. + +## Stack / onde corre + +| Item | Valor | +|---|---| +| Host | BIG CT **120** `web-traefik` | +| Pasta live | `/opt/boiler-energy` | +| Serviço | `boiler-energy.service` (user `boiler`) | +| Porta | **8788** | +| Traefik | `Host(boiler.myvexx.com)` → `http://127.0.0.1:8788` | +| Túnel | `7233d455-…` **config remota** Cloudflare (`config_src: cloudflare`) | +| Device Tuya | `bfaf766aa915197295qju6` · CB01-SBL · categoria `kg` | +| DB | `/opt/boiler-energy/data/boiler.db` (não versionar) | + +Credenciais Tuya: `boiler.env` no CT120 (não no Git). Token DNS: `Infra-Network/SECRETS_CLOUDFLARE_MYVEXX.md`. + +## Fora de escopo + +- Medição real de corrente/tensão neste interruptor +- Controlo ON/OFF pelo dashboard +- Alterar `/etc/network/interfaces` do Proxmox +- Misturar com o Tapo do R630 \ No newline at end of file diff --git a/Boiler-Tuya-Energy/README.md b/Boiler-Tuya-Energy/README.md new file mode 100644 index 0000000..0e9f27e --- /dev/null +++ b/Boiler-Tuya-Energy/README.md @@ -0,0 +1,10 @@ +# Boiler Tuya Energy + +Projeto dedicado (não é o dashboard Tapo do R630). + +- Pasta Obsidian: `Boiler-Tuya-Energy/` +- Live: CT120 `/opt/boiler-energy` +- Public: https://boiler.myvexx.com/ +- Spec/ops: [PROJECT.md](PROJECT.md) + +Código neste folder: `tuya_boiler_server.py`, `boiler-energy.service`, `boiler.env.example`. \ No newline at end of file diff --git a/Boiler-Tuya-Energy/boiler-energy.service b/Boiler-Tuya-Energy/boiler-energy.service new file mode 100644 index 0000000..7e9417a --- /dev/null +++ b/Boiler-Tuya-Energy/boiler-energy.service @@ -0,0 +1,23 @@ +[Unit] +Description=Boiler Tuya estimated energy dashboard +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=boiler +Group=boiler +WorkingDirectory=/opt/boiler-energy +EnvironmentFile=/opt/boiler-energy/boiler.env +Environment=PYTHONPATH=/opt/boiler-energy/site-packages +ExecStart=/usr/bin/python3 /opt/boiler-energy/tuya_boiler_server.py +Restart=always +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/boiler-energy/data + +[Install] +WantedBy=multi-user.target diff --git a/Boiler-Tuya-Energy/boiler.env.example b/Boiler-Tuya-Energy/boiler.env.example new file mode 100644 index 0000000..261cbe5 --- /dev/null +++ b/Boiler-Tuya-Energy/boiler.env.example @@ -0,0 +1,10 @@ +TUYA_ACCESS_ID= +TUYA_ACCESS_KEY= +TUYA_API_ENDPOINT=https://openapi.tuyaeu.com +TUYA_DEVICE_ID=bfaf766aa915197295qju6 +BOILER_POWER_KW=2.3 +BOILER_RATE_EUR_KWH=0.3652 +BOILER_TIMEZONE=Europe/Dublin +BOILER_PORT=8788 +BOILER_DB_PATH=/opt/boiler-energy/data/boiler.db +BOILER_POLL_SECONDS=300 \ No newline at end of file diff --git a/Boiler-Tuya-Energy/docker-compose.yml b/Boiler-Tuya-Energy/docker-compose.yml new file mode 100644 index 0000000..231c5da --- /dev/null +++ b/Boiler-Tuya-Energy/docker-compose.yml @@ -0,0 +1,17 @@ +services: + boiler-energy: + build: + context: . + dockerfile: Dockerfile + container_name: boiler-energy + restart: unless-stopped + env_file: + - boiler.env + network_mode: host + read_only: true + tmpfs: + - /tmp + volumes: + - ./data:/data + security_opt: + - no-new-privileges:true diff --git a/Boiler-Tuya-Energy/tuya_boiler_server.py b/Boiler-Tuya-Energy/tuya_boiler_server.py new file mode 100644 index 0000000..3e573bb --- /dev/null +++ b/Boiler-Tuya-Energy/tuya_boiler_server.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +"""Dashboard read-only do boiler Tuya com histórico SQLite e consumo estimado.""" +from __future__ import annotations + +import asyncio +import csv +import hashlib +import hmac +import io +import json +import os +import sqlite3 +import time +import urllib.parse +import urllib.request +from datetime import date, datetime, time as dt_time, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +from aiohttp import web + + +DEVICE_ID = os.environ["TUYA_DEVICE_ID"] +ACCESS_ID = os.environ["TUYA_ACCESS_ID"] +ACCESS_KEY = os.environ["TUYA_ACCESS_KEY"] +ENDPOINT = os.getenv("TUYA_API_ENDPOINT", "https://openapi.tuyaeu.com") +DB_PATH = Path(os.getenv("BOILER_DB_PATH", "/home/admin/boiler-energy/data/boiler.db")) +PORT = int(os.getenv("BOILER_PORT", "8788")) +POWER_KW = float(os.getenv("BOILER_POWER_KW", "2.3")) +UNIT_RATE = float(os.getenv("BOILER_RATE_EUR_KWH", "0.3652")) +POLL_SECONDS = int(os.getenv("BOILER_POLL_SECONDS", "300")) +TZ = ZoneInfo(os.getenv("BOILER_TIMEZONE", "Europe/Dublin")) + + +class TuyaOpenAPI: + """Minimal Tuya REST client; avoids the unused Pulsar/Crypto dependency.""" + + def __init__(self, endpoint: str, access_id: str, access_key: str) -> None: + self.endpoint = endpoint.rstrip("/") + self.access_id = access_id + self.access_key = access_key + self.token = "" + self.token_expires_ms = 0 + + def _request(self, method: str, path: str, params: dict | None = None) -> dict: + params = params or {} + canonical_query = "&".join(f"{key}={params[key]}" for key in sorted(params)) + canonical_path = path + (f"?{canonical_query}" if canonical_query else "") + body_hash = hashlib.sha256(b"").hexdigest() + string_to_sign = f"{method}\n{body_hash}\n\n{canonical_path}" + now_ms = int(time.time() * 1000) + message = f"{self.access_id}{self.token}{now_ms}{string_to_sign}" + signature = hmac.new( + self.access_key.encode(), message.encode(), hashlib.sha256 + ).hexdigest().upper() + query = urllib.parse.urlencode(sorted(params.items())) + url = self.endpoint + path + (f"?{query}" if query else "") + request = urllib.request.Request( + url, + method=method, + headers={ + "client_id": self.access_id, + "sign": signature, + "sign_method": "HMAC-SHA256", + "access_token": self.token, + "t": str(now_ms), + "lang": "en", + }, + ) + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read()) + + def connect(self) -> dict: + self.token = "" + result = self._request("GET", "/v1.0/token", {"grant_type": 1}) + if result.get("success"): + payload = result.get("result", {}) + self.token = payload.get("access_token", "") + self.token_expires_ms = int(time.time() * 1000) + int( + payload.get("expire_time", 7200) + ) * 1000 + return result + + def get(self, path: str, params: dict | None = None) -> dict: + if not self.token or self.token_expires_ms - 60000 < int(time.time() * 1000): + connected = self.connect() + if not connected.get("success"): + return connected + return self._request("GET", path, params) + + +def iso_local(ms: int) -> str: + return datetime.fromtimestamp(ms / 1000, timezone.utc).astimezone(TZ).isoformat() + + +class Store: + def __init__(self) -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + self.db = sqlite3.connect(DB_PATH) + self.db.row_factory = sqlite3.Row + self.db.execute("PRAGMA journal_mode=WAL") + self.db.executescript( + """ + CREATE TABLE IF NOT EXISTS switch_events ( + event_time_ms INTEGER NOT NULL, + value INTEGER NOT NULL CHECK(value IN (0,1)), + event_from TEXT NOT NULL DEFAULT '', + received_at TEXT NOT NULL, + PRIMARY KEY(event_time_ms, value, event_from) + ); + CREATE INDEX IF NOT EXISTS idx_switch_events_time + ON switch_events(event_time_ms); + CREATE TABLE IF NOT EXISTS sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + ok INTEGER NOT NULL DEFAULT 0, + fetched INTEGER NOT NULL DEFAULT 0, + inserted INTEGER NOT NULL DEFAULT 0, + error TEXT + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ) + self.db.commit() + + def add_events(self, logs: list[dict]) -> int: + before = self.db.total_changes + now = datetime.now(timezone.utc).isoformat() + for item in logs: + if item.get("code") != "switch_1": + continue + raw = str(item.get("value", "")).lower() + if raw not in {"true", "false", "1", "0", "on", "off"}: + continue + value = 1 if raw in {"true", "1", "on"} else 0 + self.db.execute( + "INSERT OR IGNORE INTO switch_events VALUES (?,?,?,?)", + (int(item["event_time"]), value, str(item.get("event_from", "")), now), + ) + self.db.commit() + return self.db.total_changes - before + + def normalized_events(self, start_ms: int | None = None, end_ms: int | None = None): + # Include the last event before the range to establish initial state. + rows: list[sqlite3.Row] = [] + if start_ms is not None: + previous = self.db.execute( + "SELECT * FROM switch_events WHERE event_time_ms < ? " + "ORDER BY event_time_ms DESC LIMIT 1", + (start_ms,), + ).fetchone() + if previous: + rows.append(previous) + query = "SELECT * FROM switch_events WHERE 1=1" + args: list[int] = [] + if start_ms is not None: + query += " AND event_time_ms >= ?" + args.append(start_ms) + if end_ms is not None: + query += " AND event_time_ms < ?" + args.append(end_ms) + query += " ORDER BY event_time_ms, event_from" + rows.extend(self.db.execute(query, args).fetchall()) + + result: list[dict] = [] + for row in rows: + event = {"ms": row["event_time_ms"], "on": bool(row["value"])} + # Tuya often emits the same transition twice from cloud/device. + if result and event["on"] == result[-1]["on"]: + continue + result.append(event) + return result + + def report(self, start_day: date, end_day: date) -> dict: + start = datetime.combine(start_day, dt_time.min, TZ) + end = datetime.combine(end_day + timedelta(days=1), dt_time.min, TZ) + start_ms = int(start.timestamp() * 1000) + end_ms = int(end.timestamp() * 1000) + events = self.normalized_events(start_ms, end_ms) + + intervals: list[tuple[datetime, datetime]] = [] + on_at: int | None = None + for event in events: + if event["on"] and on_at is None: + on_at = max(event["ms"], start_ms) + elif not event["on"] and on_at is not None: + stop = min(event["ms"], end_ms) + if stop > on_at: + intervals.append( + ( + datetime.fromtimestamp(on_at / 1000, timezone.utc).astimezone(TZ), + datetime.fromtimestamp(stop / 1000, timezone.utc).astimezone(TZ), + ) + ) + on_at = None + if on_at is not None: + stop_ms = min(int(time.time() * 1000), end_ms) + if stop_ms > on_at: + intervals.append( + ( + datetime.fromtimestamp(on_at / 1000, timezone.utc).astimezone(TZ), + datetime.fromtimestamp(stop_ms / 1000, timezone.utc).astimezone(TZ), + ) + ) + + daily: dict[date, dict] = {} + cursor = start_day + while cursor <= end_day: + daily[cursor] = {"date": cursor.isoformat(), "seconds": 0.0, "starts": 0} + cursor += timedelta(days=1) + + for begin, finish in intervals: + daily[begin.date()]["starts"] += 1 + point = begin + while point < finish: + midnight = datetime.combine(point.date() + timedelta(days=1), dt_time.min, TZ) + segment_end = min(finish, midnight) + if point.date() in daily: + daily[point.date()]["seconds"] += (segment_end - point).total_seconds() + point = segment_end + + rows = [] + for item in daily.values(): + hours = item.pop("seconds") / 3600 + kwh = hours * POWER_KW + rows.append( + { + **item, + "runtime_min": round(hours * 60, 1), + "kwh": round(kwh, 3), + "cost_eur": round(kwh * UNIT_RATE, 2), + } + ) + total_min = sum(row["runtime_min"] for row in rows) + total_kwh = sum(row["kwh"] for row in rows) + return { + "ok": True, + "from": start_day.isoformat(), + "to": end_day.isoformat(), + "power_kw": POWER_KW, + "rate_eur_kwh": UNIT_RATE, + "measurement": "estimated_from_on_time", + "days": rows, + "totals": { + "starts": sum(row["starts"] for row in rows), + "runtime_min": round(total_min, 1), + "kwh": round(total_kwh, 3), + "cost_eur": round(total_kwh * UNIT_RATE, 2), + }, + } + + +class TuyaMonitor: + def __init__(self, store: Store) -> None: + self.store = store + self.api = TuyaOpenAPI(ENDPOINT, ACCESS_ID, ACCESS_KEY) + self.connected = False + self.latest = {"ok": False, "error": "a iniciar"} + self.lock = asyncio.Lock() + + def _connect(self) -> None: + result = self.api.connect() + if not result.get("success"): + raise RuntimeError(result.get("msg", "falha de autenticação Tuya")) + self.connected = True + + def _fetch_status(self) -> dict: + if not self.connected: + self._connect() + response = self.api.get(f"/v1.0/iot-03/devices/{DEVICE_ID}/status") + if not response.get("success"): + self.connected = False + raise RuntimeError(response.get("msg", "falha ao consultar status")) + values = {x["code"]: x["value"] for x in response.get("result", [])} + return values + + def _fetch_logs(self, days: int) -> list[dict]: + if not self.connected: + self._connect() + now_ms = int(time.time() * 1000) + params = { + "type": "1,2,7", + "start_time": now_ms - days * 86400 * 1000, + "end_time": now_ms, + "size": 100, + } + response = self.api.get(f"/v1.0/devices/{DEVICE_ID}/logs", params) + if not response.get("success"): + self.connected = False + raise RuntimeError(response.get("msg", "falha ao consultar logs")) + return response.get("result", {}).get("logs", []) + + async def sync(self, days: int = 14) -> dict: + async with self.lock: + try: + # tuya-connector keeps the access token on the client; serialize its calls. + status = await asyncio.to_thread(self._fetch_status) + logs = await asyncio.to_thread(self._fetch_logs, days) + inserted = self.store.add_events(logs) + now = datetime.now(TZ) + today = self.store.report(now.date(), now.date()) + self.latest = { + "ok": True, + "ts": now.isoformat(timespec="seconds"), + "device_id": DEVICE_ID, + "name": "Boiler Shower", + "is_on": bool(status.get("switch_1")), + "online": True, + "power_kw_assumed": POWER_KW, + "watts_estimated": POWER_KW * 1000 if status.get("switch_1") else 0, + "today": today["totals"], + "events_fetched": len(logs), + "events_inserted": inserted, + "measurement": "ESTIMATIVA por tempo ON; este Tuya não mede watts/kWh", + } + return self.latest + except Exception as exc: + self.latest = { + "ok": False, + "online": False, + "error": f"{type(exc).__name__}: {exc}", + } + return self.latest + + async def loop(self) -> None: + await self.sync(days=90) + while True: + await asyncio.sleep(POLL_SECONDS) + await self.sync(days=7) + + +INDEX_HTML = r""" + +Boiler · Tuya +
Boiler Shower · Tuya
+
Interruptor do boiler
a iniciar…
+
Potência configurada: 2,3 kW · apenas leitura
+
Hoje
kWh estimados
+
Custo hoje
36,52 c/kWh
+
Tempo hoje
relé ON
+
Ligações hoje
ciclos detectados
+
Relatório por período
+ CSV +
+
DataONminkWh
+
Estimativa: este modelo Tuya não possui medidor elétrico. kWh = 2,3 kW × tempo em que o relé ficou ON. Um termóstato interno pode reduzir o consumo real.
+
""" + + +store = Store() +monitor = TuyaMonitor(store) + + +def parse_range(request: web.Request) -> tuple[date, date]: + today = datetime.now(TZ).date() + start = date.fromisoformat(request.query.get("from", (today - timedelta(days=29)).isoformat())) + end = date.fromisoformat(request.query.get("to", today.isoformat())) + if end < start: + raise ValueError("data final anterior à inicial") + if (end - start).days > 730: + raise ValueError("intervalo máximo: 730 dias") + return start, end + + +async def index(_request: web.Request) -> web.Response: + return web.Response(text=INDEX_HTML, content_type="text/html") + + +async def boiler_redirect(_request: web.Request) -> web.Response: + raise web.HTTPPermanentRedirect("/boiler/") + + +async def api_status(_request: web.Request) -> web.Response: + if not monitor.latest.get("ok"): + await monitor.sync(days=14) + return web.json_response(monitor.latest) + + +async def api_sync(_request: web.Request) -> web.Response: + return web.json_response(await monitor.sync(days=90)) + + +async def api_report(request: web.Request) -> web.Response: + try: + start, end = parse_range(request) + return web.json_response(store.report(start, end)) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def api_report_csv(request: web.Request) -> web.Response: + try: + start, end = parse_range(request) + report = store.report(start, end) + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["data", "ligacoes", "minutos_on", "kwh_estimados", "custo_eur"]) + for row in report["days"]: + writer.writerow( + [row["date"], row["starts"], row["runtime_min"], row["kwh"], row["cost_eur"]] + ) + return web.Response( + text=output.getvalue(), + content_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="boiler_{start}_{end}.csv"' + }, + ) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def on_start(app: web.Application) -> None: + app["collector"] = asyncio.create_task(monitor.loop()) + + +async def on_stop(app: web.Application) -> None: + app["collector"].cancel() + + +def main() -> None: + app = web.Application() + app.router.add_get("/", index) + app.router.add_get("/api/status", api_status) + app.router.add_post("/api/sync", api_sync) + app.router.add_get("/api/report", api_report) + app.router.add_get("/api/report.csv", api_report_csv) + app.router.add_get("/boiler", boiler_redirect) + app.router.add_get("/boiler/", index) + app.router.add_get("/boiler/api/status", api_status) + app.router.add_get("/boiler/api/report", api_report) + app.router.add_get("/boiler/api/report.csv", api_report_csv) + app.on_startup.append(on_start) + app.on_cleanup.append(on_stop) + web.run_app(app, host="0.0.0.0", port=PORT, print=None) + + +if __name__ == "__main__": + main() diff --git a/Infra-Network/CURSOR_TAPO_TUYA_MCP.md b/Infra-Network/CURSOR_TAPO_TUYA_MCP.md index 88d6262..f357922 100644 --- a/Infra-Network/CURSOR_TAPO_TUYA_MCP.md +++ b/Infra-Network/CURSOR_TAPO_TUYA_MCP.md @@ -172,4 +172,13 @@ Discovery: `TAPO_DISCOVERY_TARGET=192.168.1.255` — só na mesma Wi-Fi. Noutro ## Links internos - [[WELCOME]] -- [[INFRA/INDEX]] \ No newline at end of file +- [[INFRA/INDEX]] + +--- + +## 9. Boiler Tuya (projeto separado do R630) + +- Pasta vault: `Boiler-Tuya-Energy/` +- App: https://boiler.myvexx.com/ +- Live: CT120 `/opt/boiler-energy` porta 8788, SQLite, estimativa 2,3 kW +- Nao misturar com Tapo P110 `r630.myvexx.com` diff --git a/Infra-Network/SECRETS_CLOUDFLARE_MYVEXX.md b/Infra-Network/SECRETS_CLOUDFLARE_MYVEXX.md index 09d078b..6ce29d4 100644 --- a/Infra-Network/SECRETS_CLOUDFLARE_MYVEXX.md +++ b/Infra-Network/SECRETS_CLOUDFLARE_MYVEXX.md @@ -1,6 +1,6 @@ # Cloudflare — zona myvexx.com -**NÃO commitar. NÃO partilhar no chat.** Token de API (Edit zone DNS + tunnel). +Token de API (Edit zone DNS + tunnel). **Repo privado Forgejo.** Projeto app: `Boiler-Tuya-Energy/`. - **Zona:** myvexx.com - **Zone ID:** `aa0aa51acf9f88579209a6b6326f1032` diff --git a/_sidebar.md b/_sidebar.md index 389f009..311922a 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -13,6 +13,11 @@ - [SPEC VM500 Rent Charge](Infra-Network/SPEC-VM500-RENTCHARGE.md) - [Cursor Tapo Tuya MCP](Infra-Network/CURSOR_TAPO_TUYA_MCP.md) --- +## Casa Dublin (energia) +- [Boiler Tuya hub](Boiler-Tuya-Energy.md) +- [Boiler PROJECT](Boiler-Tuya-Energy/PROJECT.md) +- [R630 Tapo dashboard](https://r630.myvexx.com/) +--- ## MyVexx / Invest - [Invest hub](Invest-MyVexx/Invest-MyVexx.md) - [SPEC-050 AI Agents](Invest-MyVexx/SPEC-050-INVEST-AI-AGENTS-PAPER.md)