Add support for Shelly Cloud API

This commit is contained in:
Ettore
2026-05-14 19:22:07 +02:00
parent adcc2b9522
commit 46ba26a86d
7 changed files with 272 additions and 69 deletions

View File

@@ -2,7 +2,7 @@ import os
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Boolean, Double, String, Text, create_engine from sqlalchemy import Boolean, Double, String, Text, create_engine, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from core.config import DATA_DIR, DATABASE_URL from core.config import DATA_DIR, DATABASE_URL
@@ -20,12 +20,14 @@ class GateDB(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian' gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian'
avconnect_macro_id: Mapped[str] = mapped_column(String, nullable=False) # AVConnect macro ID api_provider: Mapped[str] = mapped_column(String, nullable=False, default="avconnect") # 'avconnect' | 'shelly'
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled' avconnect_macro_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # AVConnect macro ID
group_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) # display group label shelly_device_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Shelly Cloud device ID
lat: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 latitude status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
lon: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 longitude group_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) # display group label
lat: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 latitude
lon: Mapped[Optional[float]] = mapped_column(Double, nullable=True) # WGS-84 longitude
class ApiCredential(Base): class ApiCredential(Base):
@@ -38,6 +40,14 @@ class ApiCredential(Base):
mock_avconnect: Mapped[bool] = mapped_column(Boolean, default=False) mock_avconnect: Mapped[bool] = mapped_column(Boolean, default=False)
class ShellyCredential(Base):
__tablename__ = "shelly_credentials"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
server_uri: Mapped[str] = mapped_column(String, nullable=False) # e.g. https://shelly-3.eu.shelly.cloud
auth_key_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted auth key
class Keypass(Base): class Keypass(Base):
__tablename__ = "keypasses" __tablename__ = "keypasses"

View File

@@ -81,7 +81,9 @@ class GateResponse(BaseModel):
id: int id: int
name: str name: str
gate_type: str gate_type: str
avconnect_macro_id: str api_provider: str
avconnect_macro_id: Optional[str] = None
shelly_device_id: Optional[str] = None
status: str status: str
group_name: Optional[str] = None group_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
@@ -101,15 +103,17 @@ class GatePublicResponse(BaseModel):
class GateCreate(BaseModel): class GateCreate(BaseModel):
name: str name: str
gate_type: str # 'car' | 'pedestrian' gate_type: str # 'car' | 'pedestrian'
avconnect_macro_id: str api_provider: str = "avconnect" # 'avconnect' | 'shelly'
avconnect_macro_id: Optional[str] = None
shelly_device_id: Optional[str] = None
status: str = "enabled" status: str = "enabled"
group_name: Optional[str] = None group_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
# ── AVConnect Credentials ───────────────────────────────────────────────────── # ── API Credentials ──────────────────────────────────────────────────────────
class CredentialRead(BaseModel): class CredentialRead(BaseModel):
id: int id: int
@@ -121,6 +125,16 @@ class CredentialUpsert(BaseModel):
password: str password: str
class ShellyCredentialRead(BaseModel):
id: int
server_uri: str
class ShellyCredentialUpsert(BaseModel):
server_uri: str
auth_key: str
# ── Admin users ─────────────────────────────────────────────────────────────── # ── Admin users ───────────────────────────────────────────────────────────────
class AdminUserResponse(BaseModel): class AdminUserResponse(BaseModel):

View File

@@ -5,14 +5,17 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.auth import encrypt_secret from core.auth import encrypt_secret
from core.database import ApiCredential, get_db from core.database import ApiCredential, ShellyCredential, get_db
from core.dependencies import require_admin from core.dependencies import require_admin
from core.schemas import CredentialRead, CredentialUpsert from core.schemas import CredentialRead, CredentialUpsert, ShellyCredentialRead, ShellyCredentialUpsert
from services.avconnect import AVConnectAPI from services.avconnect import AVConnectAPI
from services.shelly import ShellyCloudAPI
router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"]) router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
# ── AVConnect credentials ─────────────────────────────────────────────────────
@router.get("", response_model=list[CredentialRead]) @router.get("", response_model=list[CredentialRead])
async def list_credentials( async def list_credentials(
db: Session = Depends(get_db), _: dict = Depends(require_admin) db: Session = Depends(get_db), _: dict = Depends(require_admin)
@@ -37,7 +40,7 @@ async def upsert_credential(
if cred: if cred:
cred.username = req.username cred.username = req.username
cred.password_enc = encrypt_secret(req.password) cred.password_enc = encrypt_secret(req.password)
cred.session_id = session_id # reuse the session obtained during validation cred.session_id = session_id
else: else:
cred = ApiCredential( cred = ApiCredential(
username=req.username, username=req.username,
@@ -50,7 +53,47 @@ async def upsert_credential(
return CredentialRead(id=cred.id, username=cred.username) return CredentialRead(id=cred.id, username=cred.username)
# ── Mock AVConnect setting ───────────────────────────────────────────────────── # ── Shelly Cloud credentials ──────────────────────────────────────────────────
@router.get("/shelly", response_model=Optional[ShellyCredentialRead])
async def get_shelly_credential(
db: Session = Depends(get_db), _: dict = Depends(require_admin)
):
cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
if not cred:
return None
return ShellyCredentialRead(id=cred.id, server_uri=cred.server_uri)
@router.put("/shelly", response_model=ShellyCredentialRead)
async def upsert_shelly_credential(
req: ShellyCredentialUpsert,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
try:
ok = ShellyCloudAPI(req.server_uri, req.auth_key).validate_credentials()
except Exception as exc:
raise HTTPException(502, f"Could not reach Shelly Cloud: {exc}")
if not ok:
raise HTTPException(422, "Shelly Cloud rejected these credentials")
cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
if cred:
cred.server_uri = req.server_uri
cred.auth_key_enc = encrypt_secret(req.auth_key)
else:
cred = ShellyCredential(
server_uri=req.server_uri,
auth_key_enc=encrypt_secret(req.auth_key),
)
db.add(cred)
db.commit()
db.refresh(cred)
return ShellyCredentialRead(id=cred.id, server_uri=cred.server_uri)
# ── Mock mode setting ─────────────────────────────────────────────────────────
class MockSettingResponse(BaseModel): class MockSettingResponse(BaseModel):
enabled: bool enabled: bool
@@ -76,7 +119,6 @@ async def set_mock_setting(
): ):
cred: Optional[ApiCredential] = db.query(ApiCredential).first() cred: Optional[ApiCredential] = db.query(ApiCredential).first()
if not cred: if not cred:
from fastapi import HTTPException
raise HTTPException(503, "AVConnect credentials not configured") raise HTTPException(503, "AVConnect credentials not configured")
cred.mock_avconnect = req.enabled cred.mock_avconnect = req.enabled
db.commit() db.commit()

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from core.auth import decrypt_secret from core.auth import decrypt_secret
from core.config import utcnow from core.config import utcnow
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, TelegramConfig, get_db from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, ShellyCredential, TelegramConfig, get_db
from core.dependencies import require_admin, require_manager, require_keypass from core.dependencies import require_admin, require_manager, require_keypass
from core.schemas import GateCreate, GatePublicResponse, GateResponse from core.schemas import GateCreate, GatePublicResponse, GateResponse
from services.gates import call_open_gate from services.gates import call_open_gate
@@ -35,6 +35,49 @@ def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None
).start() ).start()
def _validate_gate_create(req: GateCreate) -> None:
if req.gate_type not in ("car", "pedestrian"):
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
if req.api_provider not in ("avconnect", "shelly"):
raise HTTPException(400, "api_provider must be 'avconnect' or 'shelly'")
if req.api_provider == "avconnect" and not req.avconnect_macro_id:
raise HTTPException(400, "avconnect_macro_id is required for AVConnect gates")
if req.api_provider == "shelly" and not req.shelly_device_id:
raise HTTPException(400, "shelly_device_id is required for Shelly gates")
def _do_open_gate(
gate_db: GateDB,
db: Session,
mock: bool,
) -> tuple[bool, Optional[str], Optional[str]]:
"""Dispatch the gate-open call to the appropriate API provider."""
if gate_db.api_provider == "shelly":
shelly_cred: Optional[ShellyCredential] = db.query(ShellyCredential).first()
if not shelly_cred:
raise HTTPException(503, "Shelly credentials not configured")
return call_open_gate(
api_provider="shelly",
shelly_device_id=gate_db.shelly_device_id,
shelly_server_uri=shelly_cred.server_uri,
shelly_auth_key=decrypt_secret(shelly_cred.auth_key_enc),
mock=mock,
)
# AVConnect (default)
av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
if not av_cred:
raise HTTPException(503, "AVConnect credentials not configured")
return call_open_gate(
api_provider="avconnect",
avconnect_macro_id=gate_db.avconnect_macro_id,
avconnect_username=av_cred.username,
avconnect_password=decrypt_secret(av_cred.password_enc),
avconnect_session_id=av_cred.session_id,
mock=mock,
)
# ── Admin: gate CRUD ────────────────────────────────────────────────────────── # ── Admin: gate CRUD ──────────────────────────────────────────────────────────
@router.get("/api/admin/gates", response_model=list[GateResponse]) @router.get("/api/admin/gates", response_model=list[GateResponse])
@@ -50,8 +93,7 @@ async def create_gate(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
): ):
if req.gate_type not in ("car", "pedestrian"): _validate_gate_create(req)
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
gate = GateDB(**req.model_dump()) gate = GateDB(**req.model_dump())
db.add(gate) db.add(gate)
db.commit() db.commit()
@@ -66,6 +108,7 @@ async def update_gate(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
): ):
_validate_gate_create(req)
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first() gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
if not gate: if not gate:
raise HTTPException(404, "Gate not found") raise HTTPException(404, "Gate not found")
@@ -102,21 +145,14 @@ async def admin_open_gate(
if gate_db.status != "enabled": if gate_db.status != "enabled":
raise HTTPException(409, "Gate is disabled") raise HTTPException(409, "Gate is disabled")
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first() # Determine mock mode from AVConnect credential record (applies to all providers)
if not cred_db: av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
raise HTTPException(503, "AVConnect credentials not configured") mock = bool(av_cred.mock_avconnect) if av_cred else False
ip = request.client.host if request.client else None ip = request.client.host if request.client else None
ua = request.headers.get("User-Agent") ua = request.headers.get("User-Agent")
mock = bool(cred_db.mock_avconnect) success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
success, error_msg, new_sid = call_open_gate(
gate_db.avconnect_macro_id,
cred_db.username,
decrypt_secret(cred_db.password_enc),
cred_db.session_id,
mock=mock,
)
db.add(GateAccessLog( db.add(GateAccessLog(
timestamp=utcnow(), timestamp=utcnow(),
@@ -130,8 +166,8 @@ async def admin_open_gate(
error=error_msg, error=error_msg,
)) ))
if new_sid and new_sid != cred_db.session_id: if new_sid and av_cred and new_sid != av_cred.session_id:
cred_db.session_id = new_sid av_cred.session_id = new_sid
db.commit() db.commit()
if not success: if not success:
@@ -169,25 +205,17 @@ async def open_gate(
if not gate_db: if not gate_db:
raise HTTPException(404, "Gate not found or disabled") raise HTTPException(404, "Gate not found or disabled")
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
if not cred_db:
raise HTTPException(503, "AVConnect credentials not configured")
ip = request.client.host if request.client else None
ua = request.headers.get("User-Agent")
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
if allowed is not None and gate_id not in allowed: if allowed is not None and gate_id not in allowed:
raise HTTPException(403, "This keypass does not have access to this gate") raise HTTPException(403, "This keypass does not have access to this gate")
mock = bool(cred_db.mock_avconnect) av_cred: Optional[ApiCredential] = db.query(ApiCredential).first()
success, error_msg, new_sid = call_open_gate( mock = bool(av_cred.mock_avconnect) if av_cred else False
gate_db.avconnect_macro_id,
cred_db.username, ip = request.client.host if request.client else None
decrypt_secret(cred_db.password_enc), ua = request.headers.get("User-Agent")
cred_db.session_id,
mock=mock, success, error_msg, new_sid = _do_open_gate(gate_db, db, mock)
)
db.add(GateAccessLog( db.add(GateAccessLog(
timestamp=utcnow(), timestamp=utcnow(),
@@ -201,8 +229,8 @@ async def open_gate(
error=error_msg, error=error_msg,
)) ))
if new_sid and new_sid != cred_db.session_id: if new_sid and av_cred and new_sid != av_cred.session_id:
cred_db.session_id = new_sid av_cred.session_id = new_sid
db.commit() db.commit()
if not success: if not success:
@@ -210,5 +238,5 @@ async def open_gate(
raise HTTPException(502, error_msg or "Gate operation failed") raise HTTPException(502, error_msg or "Gate operation failed")
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip) logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
_notify(db, gate_db.name, f"{_kp.description} ({_kp.code})", ip) _notify(db, gate_db.name, f"{_kp.description}", ip)
return {"success": True, "gate": gate_db.name} return {"success": True, "gate": gate_db.name}

View File

@@ -1,21 +1,37 @@
from typing import Optional from typing import Optional
from .avconnect import AVConnectAPI from .avconnect import AVConnectAPI
from .shelly import ShellyCloudAPI
def call_open_gate( def call_open_gate(
macro_id: str, api_provider: str,
username: str, avconnect_macro_id: Optional[str] = None,
password: str, avconnect_username: Optional[str] = None,
session_id: Optional[str] = None, avconnect_password: Optional[str] = None,
avconnect_session_id: Optional[str] = None,
shelly_device_id: Optional[str] = None,
shelly_server_uri: Optional[str] = None,
shelly_auth_key: Optional[str] = None,
mock: bool = False, mock: bool = False,
) -> tuple[bool, Optional[str], Optional[str]]: ) -> tuple[bool, Optional[str], Optional[str]]:
"""Attempt to open a gate. Returns (success, error_msg, new_session_id).""" """Attempt to open a gate. Returns (success, error_msg, new_avconnect_session_id)."""
if mock: if mock:
return True, None, None return True, None, None
if api_provider == "shelly":
try:
assert shelly_server_uri and shelly_auth_key and shelly_device_id
ShellyCloudAPI(shelly_server_uri, shelly_auth_key).open_gate(shelly_device_id)
return True, None, None
except Exception as e:
return False, str(e), None
# Default: AVConnect
try: try:
api = AVConnectAPI(username, password, session_id) assert avconnect_username and avconnect_password and avconnect_macro_id
ok, new_sid = api.exec_gate_macro(macro_id) api = AVConnectAPI(avconnect_username, avconnect_password, avconnect_session_id)
ok, new_sid = api.exec_gate_macro(avconnect_macro_id)
if not ok: if not ok:
return False, "Gate did not confirm open", new_sid return False, "Gate did not confirm open", new_sid
return True, None, new_sid return True, None, new_sid

View File

@@ -122,7 +122,7 @@
<nav class="tabs"> <nav class="tabs">
<button class="tab-btn active" data-tab="keypasses">Keypasses</button> <button class="tab-btn active" data-tab="keypasses">Keypasses</button>
<button class="tab-btn" data-tab="gates">Gates</button> <button class="tab-btn" data-tab="gates">Gates</button>
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</button> <button class="tab-btn admin-only" data-tab="credentials">API Credentials</button>
<button class="tab-btn" data-tab="stats">Statistics</button> <button class="tab-btn" data-tab="stats">Statistics</button>
<button class="tab-btn admin-only" data-tab="admins">Admins</button> <button class="tab-btn admin-only" data-tab="admins">Admins</button>
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button> <button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
@@ -167,7 +167,8 @@
<th>Name</th> <th>Name</th>
<th>Group</th> <th>Group</th>
<th>Type</th> <th>Type</th>
<th>AVConnect Macro ID</th> <th>Provider</th>
<th>Device / Macro ID</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
</tr> </tr>
@@ -179,7 +180,10 @@
<!-- ── Credentials pane ───────────────────────────────────────────── --> <!-- ── Credentials pane ───────────────────────────────────────────── -->
<div id="tab-credentials" class="tab-pane"> <div id="tab-credentials" class="tab-pane">
<h3 style="margin-bottom:1rem">AVConnect Credentials</h3> <h3 style="margin-bottom:1rem">API Credentials</h3>
<!-- AVConnect -->
<h4 style="margin:0 0 .75rem">AVConnect</h4>
<div class="card" style="max-width:440px"> <div class="card" style="max-width:440px">
<form id="credentials-form"> <form id="credentials-form">
<div class="field"> <div class="field">
@@ -195,14 +199,34 @@
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
</form> </form>
</div> </div>
<!-- Shelly Cloud -->
<h4 style="margin:1.5rem 0 .75rem">Shelly Cloud</h4>
<div class="card" style="max-width:440px">
<form id="shelly-credentials-form">
<div class="field">
<label for="shelly-server-uri">Server URI</label>
<input id="shelly-server-uri" type="url" autocomplete="off"
placeholder="e.g. https://shelly-3.eu.shelly.cloud" />
</div>
<div class="field">
<label for="shelly-auth-key">Auth Key</label>
<input id="shelly-auth-key" type="password" autocomplete="new-password"
placeholder="Leave empty to keep current" />
</div>
<p id="shelly-cred-error" class="error-msg hidden"></p>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
<h3 style="margin:1.5rem 0 1rem">Mock Mode</h3> <h3 style="margin:1.5rem 0 1rem">Mock Mode</h3>
<div class="card" style="max-width:440px"> <div class="card" style="max-width:440px">
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1rem"> <p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1rem">
When enabled, gate open requests always succeed without contacting AVConnect. When enabled, gate open requests always succeed without contacting any API.
</p> </p>
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0"> <label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0">
<input type="checkbox" id="mock-toggle" style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" /> <input type="checkbox" id="mock-toggle" style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" />
<span style="font-weight:600">Enable mock AVConnect</span> <span style="font-weight:600">Enable mock mode</span>
</label> </label>
</div> </div>
</div> </div>
@@ -450,8 +474,19 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="gate-api-provider">API Provider</label>
<select id="gate-api-provider">
<option value="avconnect">AVConnect</option>
<option value="shelly">Shelly Cloud</option>
</select>
</div>
<div class="field" id="gate-avconnect-field">
<label for="gate-avconnect-macro-id">AVConnect Macro ID</label> <label for="gate-avconnect-macro-id">AVConnect Macro ID</label>
<input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" required /> <input id="gate-avconnect-macro-id" type="text" placeholder="e.g. 42" />
</div>
<div class="field" id="gate-shelly-field" style="display:none">
<label for="gate-shelly-device-id">Shelly Device ID</label>
<input id="gate-shelly-device-id" type="text" placeholder="e.g. e0:98:06:xx:xx:xx" />
</div> </div>
<div class="field"> <div class="field">
<label for="gate-status">Status</label> <label for="gate-status">Status</label>

View File

@@ -429,13 +429,16 @@ async function loadGates() {
const badge = g.status === "enabled" const badge = g.status === "enabled"
? '<span class="badge badge-green">Enabled</span>' ? '<span class="badge badge-green">Enabled</span>'
: '<span class="badge badge-muted">Disabled</span>'; : '<span class="badge badge-muted">Disabled</span>';
const providerLabel = g.api_provider === 'shelly' ? 'Shelly' : 'AVConnect';
const deviceId = g.api_provider === 'shelly' ? (g.shelly_device_id || '') : (g.avconnect_macro_id || '');
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${g.id}</td> <td>${g.id}</td>
<td>${esc(g.name)}</td> <td>${esc(g.name)}</td>
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)"></span>'}</td> <td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">\u2014</span>'}</td>
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td> <td>${g.gate_type === "car" ? "\u{1F698} Car" : "\u{1F6B6} Pedestrian"}</td>
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td> <td><span style="font-size:.85em">${esc(providerLabel)}</span></td>
<td><code style="font-size:.85em">${esc(deviceId)}</code></td>
<td>${badge}</td> <td>${badge}</td>
<td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end"> <td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem" ${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
@@ -481,7 +484,11 @@ function openGateModal(gate = null) {
document.getElementById("gate-name").value = gate ? gate.name : ""; document.getElementById("gate-name").value = gate ? gate.name : "";
document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : ""; document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : "";
document.getElementById("gate-type").value = gate ? gate.gate_type : "car"; document.getElementById("gate-type").value = gate ? gate.gate_type : "car";
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : ""; const provider = gate ? (gate.api_provider || "avconnect") : "avconnect";
document.getElementById("gate-api-provider").value = provider;
document.getElementById("gate-avconnect-macro-id").value = gate ? (gate.avconnect_macro_id || "") : "";
document.getElementById("gate-shelly-device-id").value = gate ? (gate.shelly_device_id || "") : "";
_updateGateProviderFields(provider);
document.getElementById("gate-status").value = gate ? gate.status : "enabled"; document.getElementById("gate-status").value = gate ? gate.status : "enabled";
document.getElementById("gate-lat").value = (gate && gate.lat != null) ? gate.lat : ""; document.getElementById("gate-lat").value = (gate && gate.lat != null) ? gate.lat : "";
document.getElementById("gate-lon").value = (gate && gate.lon != null) ? gate.lon : ""; document.getElementById("gate-lon").value = (gate && gate.lon != null) ? gate.lon : "";
@@ -498,17 +505,41 @@ function openGateModal(gate = null) {
document.getElementById("gate-modal").classList.remove("hidden"); document.getElementById("gate-modal").classList.remove("hidden");
} }
function _updateGateProviderFields(provider) {
const avField = document.getElementById("gate-avconnect-field");
const shellyField = document.getElementById("gate-shelly-field");
const avInput = document.getElementById("gate-avconnect-macro-id");
const shellyInput = document.getElementById("gate-shelly-device-id");
if (provider === "shelly") {
avField.style.display = "none";
shellyField.style.display = "";
avInput.required = false;
shellyInput.required = true;
} else {
avField.style.display = "";
shellyField.style.display = "none";
avInput.required = true;
shellyInput.required = false;
}
}
document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal()); document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal());
document.getElementById("gate-cancel").addEventListener("click", () => { document.getElementById("gate-cancel").addEventListener("click", () => {
document.getElementById("gate-modal").classList.add("hidden"); document.getElementById("gate-modal").classList.add("hidden");
}); });
document.getElementById("gate-api-provider").addEventListener("change", e => {
_updateGateProviderFields(e.target.value);
});
document.getElementById("gate-form").addEventListener("submit", async e => { document.getElementById("gate-form").addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const editId = document.getElementById("gate-edit-id").value; const editId = document.getElementById("gate-edit-id").value;
const provider = document.getElementById("gate-api-provider").value;
const payload = { const payload = {
name: document.getElementById("gate-name").value.trim(), name: document.getElementById("gate-name").value.trim(),
gate_type: document.getElementById("gate-type").value, gate_type: document.getElementById("gate-type").value,
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(), api_provider: provider,
avconnect_macro_id: provider === "avconnect" ? document.getElementById("gate-avconnect-macro-id").value.trim() : null,
shelly_device_id: provider === "shelly" ? document.getElementById("gate-shelly-device-id").value.trim() : null,
status: document.getElementById("gate-status").value, status: document.getElementById("gate-status").value,
group_name: document.getElementById("gate-group-name").value.trim() || null, group_name: document.getElementById("gate-group-name").value.trim() || null,
lat: document.getElementById("gate-lat").value !== "" ? parseFloat(document.getElementById("gate-lat").value) : null, lat: document.getElementById("gate-lat").value !== "" ? parseFloat(document.getElementById("gate-lat").value) : null,
@@ -539,6 +570,12 @@ async function loadCredentials() {
document.getElementById("cred-username").value = list[0].username; document.getElementById("cred-username").value = list[0].username;
} }
} catch { /* no creds yet */ } } catch { /* no creds yet */ }
try {
const shelly = await api("GET", "/api/admin/credentials/shelly");
if (shelly) {
document.getElementById("shelly-server-uri").value = shelly.server_uri;
}
} catch { /* no shelly creds yet */ }
try { try {
const { enabled } = await api("GET", "/api/admin/credentials/mock"); const { enabled } = await api("GET", "/api/admin/credentials/mock");
document.getElementById("mock-toggle").checked = enabled; document.getElementById("mock-toggle").checked = enabled;
@@ -569,7 +606,28 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
try { try {
await api("PUT", "/api/admin/credentials", { username, password }); await api("PUT", "/api/admin/credentials", { username, password });
document.getElementById("cred-password").value = ""; document.getElementById("cred-password").value = "";
showToast("Credentials saved"); showToast("AVConnect credentials saved");
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove("hidden");
}
});
document.getElementById("shelly-credentials-form").addEventListener("submit", async e => {
e.preventDefault();
const server_uri = document.getElementById("shelly-server-uri").value.trim();
const auth_key = document.getElementById("shelly-auth-key").value;
const errEl = document.getElementById("shelly-cred-error");
errEl.classList.add("hidden");
if (!auth_key) {
errEl.textContent = "Auth key is required.";
errEl.classList.remove("hidden");
return;
}
try {
await api("PUT", "/api/admin/credentials/shelly", { server_uri, auth_key });
document.getElementById("shelly-auth-key").value = "";
showToast("Shelly Cloud credentials saved");
} catch (e) { } catch (e) {
errEl.textContent = e.message; errEl.textContent = e.message;
errEl.classList.remove("hidden"); errEl.classList.remove("hidden");