Fix SonoffLAN signing key: hardcode pre-computed HMAC key

The previous _compute_sign_key() function indexed into a base64 string
derived from the full SonoffLAN REGIONS dict (~243 entries). Our partial
dict only produced a 7876-char a string but needed index 7872+, so the
function must use the full dict.

Solution: pre-compute the key once from the full dict and hardcode the
resulting 32-byte ASCII key. This is deterministic — the SonoffLAN
algorithm always produces the same output regardless of when it runs.

The sonoff_ewelink driver now loads cleanly alongside all other drivers.
This commit is contained in:
ske087
2026-02-26 20:13:07 +02:00
parent 7a22575dab
commit 30806560a6
17 changed files with 1864 additions and 19 deletions

View File

@@ -27,7 +27,7 @@
</li>
<li>
<a href="{{ url_for('boards.list_boards') }}"
class="nav-link text-white {% if 'boards.' in request.endpoint %}active{% endif %}">
class="nav-link text-white {% if 'boards.' in request.endpoint or 'sonoff.' in request.endpoint %}active{% endif %}">
<i class="bi bi-motherboard me-2"></i>Boards
</a>
</li>

View File

@@ -28,24 +28,42 @@
</select>
<div class="form-text text-secondary" id="driver_desc"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label">IP Address / Hostname</label>
<input type="text" name="host" class="form-control font-monospace" placeholder="192.168.1.100" required />
{# ── 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="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
<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>
<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="1" 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" />
{# ── 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>
<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>
</div>
</div>
<div class="d-flex gap-2">
@@ -69,6 +87,12 @@ function updateDefaults(type) {
document.getElementById("num_relays").value = d.relays;
document.getElementById("num_inputs").value = d.inputs;
document.getElementById("driver_desc").textContent = d.desc;
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);

View File

@@ -22,7 +22,15 @@
<tbody>
{% for b in boards %}
<tr>
<td><a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a></td>
<td>
{% if b.board_type == 'sonoff_ewelink' %}
<a href="{{ url_for('sonoff.gateway', board_id=b.id) }}" class="fw-semibold text-decoration-none">
<i class="bi bi-cloud-lightning-fill text-warning me-1"></i>{{ b.name }}
</a>
{% else %}
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a>
{% endif %}
</td>
<td><span class="badge text-bg-secondary">{{ b.board_type }}</span></td>
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
<td>{{ b.num_relays }}</td>
@@ -36,7 +44,7 @@
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
<a href="{% if b.board_type == 'sonoff_ewelink' %}{{ url_for('sonoff.gateway', board_id=b.id) }}{% else %}{{ url_for('boards.board_detail', board_id=b.id) }}{% endif %}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.edit_board', board_id=b.id) }}" class="btn btn-sm btn-outline-secondary me-1"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('boards.delete_board', board_id=b.id) }}" class="d-inline"

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} eWeLink Settings{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:560px">
<a href="{{ url_for('sonoff.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-key-fill me-2 text-warning"></i>eWeLink Account Settings</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">
Enter your eWeLink account credentials. These are stored on the server and used
to authenticate with the eWeLink cloud API.
<strong>Tip:</strong> create a secondary eWeLink account and share your devices
with it to avoid being logged out of the mobile app.
</p>
<form method="post">
<div class="mb-3">
<label class="form-label">Email or Phone Number</label>
<input type="text" name="username" class="form-control"
value="{{ board.config.get('ewelink_username', '') }}"
placeholder="you@example.com" required>
<div class="form-text text-muted">Use email or full phone number (e.g. +1234567890)</div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" name="password" id="pwdInput" class="form-control"
placeholder="eWeLink password" required>
<button type="button" class="btn btn-outline-secondary" id="pwdToggle">
<i class="bi bi-eye" id="pwdIcon"></i>
</button>
</div>
</div>
<div class="mb-4">
<label class="form-label">Country / Region</label>
<select name="country_code" class="form-select">
{% for code, label in countries %}
<option value="{{ code }}" {% if code == current_country %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<div class="form-text text-muted">
Determines which eWeLink server region to use (EU, US, AS, CN)
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save & Connect
</button>
<a href="{{ url_for('sonoff.gateway', board_id=board.id) }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
{% if board.config.get('ewelink_at') %}
<hr class="border-secondary mt-4">
<p class="small text-success mb-0">
<i class="bi bi-check-circle me-1"></i>
Currently connected to eWeLink
(region: <strong>{{ board.config.get('ewelink_region', '?') }}</strong>)
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('pwdToggle').addEventListener('click', function() {
const inp = document.getElementById('pwdInput');
const icon = document.getElementById('pwdIcon');
if (inp.type === 'password') {
inp.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
inp.type = 'password';
icon.className = 'bi bi-eye';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}{{ device.name or device.device_id }} Sonoff Device{% endblock %}
{% block content %}
<div class="container py-3" style="max-width:720px">
{# breadcrumb #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item">
<a href="{{ url_for('boards.list_boards') }}" class="text-decoration-none">Boards</a>
</li>
<li class="breadcrumb-item">
<a href="{{ url_for('sonoff.gateway', board_id=board.id) }}" class="text-decoration-none">
{{ board.name }}
</a>
</li>
<li class="breadcrumb-item active">{{ device.name or device.device_id }}</li>
</ol>
</nav>
{% 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 %}
{# ── Device header card ───────────────────────────────────────────── #}
<div class="card bg-dark border-secondary mb-3">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap">
<div>
<h4 class="mb-1 fw-bold">
{% set kind_icon = {
'switch': 'bi-toggles', 'light': 'bi-lightbulb-fill',
'fan': 'bi-fan', 'sensor': 'bi-thermometer-half',
'remote': 'bi-broadcast', 'cover': 'bi-door-open'
}.get(device.kind, 'bi-plug') %}
<i class="bi {{ kind_icon }} text-warning me-2"></i>
{{ device.name or device.device_id }}
</h4>
<div class="text-muted small">
{{ device.device_label }}
{% if device.firmware %} &nbsp;·&nbsp; v{{ device.firmware }}{% endif %}
{% if device.model %} &nbsp;·&nbsp; {{ device.model }}{% endif %}
</div>
<div class="mt-1">
{% if device.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
</div>
</div>
{# Rename form #}
{% if current_user.is_admin() %}
<form method="post" action="{{ url_for('sonoff.rename_device', board_id=board.id, device_id=device.device_id) }}"
class="d-flex gap-2 align-items-center">
<input type="text" name="name" class="form-control form-control-sm"
value="{{ device.name }}" maxlength="64" placeholder="Device name" style="width:180px">
<button type="submit" class="btn btn-outline-light btn-sm">
<i class="bi bi-pencil"></i>
</button>
</form>
{% endif %}
</div>
</div>
</div>
{# ── Channel controls ─────────────────────────────────────────────── #}
{% if device.kind in ('switch', 'light', 'fan') and device.num_channels > 0 %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-toggles me-1"></i>Channels
</div>
<div class="card-body py-2">
<div class="row g-3">
{% for ch in device.all_channel_states() %}
{% set ch_on = ch.state %}
<div class="col-6 col-sm-4" id="ch-{{ device.device_id }}-{{ ch.channel }}">
<div class="border border-secondary rounded p-2 text-center">
<div class="small text-muted mb-2">{{ ch.label }}</div>
<div class="d-flex justify-content-center gap-2">
<button
class="btn btn-sm sonoff-ch-btn {{ 'btn-success' if ch_on else 'btn-outline-success' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=device.device_id, channel=ch.channel, action='on') }}"
data-device="{{ device.device_id }}" data-ch="{{ ch.channel }}" data-target="on">ON
</button>
<button
class="btn btn-sm sonoff-ch-btn {{ 'btn-danger' if not ch_on else 'btn-outline-danger' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=device.device_id, channel=ch.channel, action='off') }}"
data-device="{{ device.device_id }}" data-ch="{{ ch.channel }}" data-target="off">OFF
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# ── Sensor readings ──────────────────────────────────────────────── #}
{% if device.temperature is not none or device.humidity is not none or device.power is not none %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-bar-chart-fill me-1"></i>Sensor Readings
</div>
<div class="card-body">
<div class="row text-center g-3">
{% if device.temperature is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-info">{{ device.temperature }}°C</div>
<div class="small text-muted">Temperature</div>
</div>
{% endif %}
{% if device.humidity is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-primary">{{ device.humidity }}%</div>
<div class="small text-muted">Humidity</div>
</div>
{% endif %}
{% if device.power is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-warning">{{ device.power }}W</div>
<div class="small text-muted">Power</div>
</div>
{% endif %}
{% if device.voltage is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-success">{{ device.voltage }}V</div>
<div class="small text-muted">Voltage</div>
</div>
{% endif %}
{% if device.current is not none %}
<div class="col-4">
<div class="fs-3 fw-bold text-danger">{{ device.current }}A</div>
<div class="small text-muted">Current</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{# ── Device info ──────────────────────────────────────────────────── #}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-info-circle me-1"></i>Device Info
</div>
<div class="card-body p-0">
<table class="table table-dark table-sm mb-0">
<tbody>
<tr><th class="ps-3 text-muted" width="140">Device ID</th><td><code>{{ device.device_id }}</code></td></tr>
<tr><th class="ps-3 text-muted">UIID</th><td>{{ device.uiid }} ({{ device.device_label }})</td></tr>
<tr><th class="ps-3 text-muted">Type</th><td>{{ device.kind }}</td></tr>
<tr><th class="ps-3 text-muted">Channels</th><td>{{ device.num_channels }}</td></tr>
{% if device.firmware %}
<tr><th class="ps-3 text-muted">Firmware</th><td>{{ device.firmware }}</td></tr>
{% endif %}
<tr>
<th class="ps-3 text-muted">LAN IP</th>
<td>{{ device.ip_address or '<span class="text-muted">Not detected</span>'|safe }}</td>
</tr>
<tr>
<th class="ps-3 text-muted">Connection</th>
<td>
{% if device.ip_address %}
<span class="text-success"><i class="bi bi-wifi me-1"></i>LAN available</span>
{% else %}
<span class="text-warning"><i class="bi bi-cloud me-1"></i>Cloud only</span>
{% endif %}
</td>
</tr>
{% if device.last_seen %}
<tr>
<th class="ps-3 text-muted">Last Seen</th>
<td>{{ device.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{# ── Set LAN IP manually ──────────────────────────────────────────── #}
{% if current_user.is_admin() %}
<div class="card bg-dark border-secondary mb-3">
<div class="card-header small text-muted fw-semibold text-uppercase">
<i class="bi bi-wifi me-1"></i>Manual LAN Override
</div>
<div class="card-body">
<p class="text-muted small mb-3">
By default the LAN IP is auto-detected during cloud sync.
You can override it here if the device is reachable on a static IP.
</p>
<form method="post"
action="{{ url_for('sonoff.set_device_ip', board_id=board.id, device_id=device.device_id) }}"
class="row g-2 align-items-end">
<div class="col-sm-6">
<label class="form-label form-label-sm">IP Address</label>
<input type="text" name="ip" class="form-control form-control-sm"
value="{{ device.ip_address }}" placeholder="192.168.1.100">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Port</label>
<input type="number" name="port" class="form-control form-control-sm"
value="{{ device.port or 8081 }}" min="1" max="65535">
</div>
<div class="col-sm-3">
<button type="submit" class="btn btn-sm btn-outline-light w-100">
<i class="bi bi-save me-1"></i>Save
</button>
</div>
</form>
</div>
</div>
{% endif %}
{# ── Raw params ───────────────────────────────────────────────────── #}
<div class="card bg-dark border-secondary">
<a class="card-header text-decoration-none text-muted small fw-semibold text-uppercase d-flex justify-content-between"
data-bs-toggle="collapse" href="#rawParams">
<span><i class="bi bi-code-slash me-1"></i>Raw Params</span>
<i class="bi bi-chevron-down"></i>
</a>
<div class="collapse" id="rawParams">
<div class="card-body">
<pre class="text-muted small mb-0" style="white-space:pre-wrap;word-break:break-all">{{ device.params_json }}</pre>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const _socket = io();
document.querySelectorAll(".sonoff-ch-btn").forEach(btn => {
btn.addEventListener("click", async function() {
const url = this.dataset.url;
const did = this.dataset.device;
const ch = parseInt(this.dataset.ch);
this.disabled = true;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json" },
});
const data = await resp.json();
if (data.ok) _applyChannelState(did, ch, data.state);
} catch(e) { console.error(e); }
finally { this.disabled = false; }
});
});
function _applyChannelState(did, ch, isOn) {
const container = document.getElementById(`ch-${did}-${ch}`);
if (!container) return;
const [onBtn, offBtn] = container.querySelectorAll(".sonoff-ch-btn");
if (isOn) {
onBtn.className = onBtn.className.replace("btn-outline-success", "btn-success");
offBtn.className = offBtn.className.replace("btn-danger", "btn-outline-danger");
} else {
onBtn.className = onBtn.className.replace("btn-success", "btn-outline-success");
offBtn.className = offBtn.className.replace("btn-outline-danger", "btn-danger");
}
}
_socket.on("sonoff_update", function(data) {
if (data.device_id !== "{{ device.device_id }}") return;
_applyChannelState(data.device_id, data.channel, data.state);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,260 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} Sonoff 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-lightning-fill text-warning me-2"></i>{{ board.name }}
</h4>
<small class="text-muted">Sonoff eWeLink 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('sonoff.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('sonoff.auth_settings', board_id=board.id) }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-key me-1"></i>eWeLink 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 credentials warning ───────────────────────────────────────── #}
{% if not has_credentials %}
<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>eWeLink credentials not configured.</strong>
<a href="{{ url_for('sonoff.auth_settings', board_id=board.id) }}" class="alert-link">
Set up your account
</a> to discover and control devices.
</div>
</div>
{% endif %}
{# ── Device grid ──────────────────────────────────────────────────── #}
{% if devices %}
<div class="row g-3">
{% for dev in devices %}
<div class="col-12 col-md-6 col-xl-4" id="sonoff-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">
{# Kind icon #}
{% set kind_icon = {
'switch': 'bi-toggles', 'light': 'bi-lightbulb-fill',
'fan': 'bi-fan', 'sensor': 'bi-thermometer-half',
'remote': 'bi-broadcast', 'cover': 'bi-door-open'
}.get(dev.kind, 'bi-plug') %}
<i class="bi {{ kind_icon }} text-warning"></i>
<span class="fw-semibold text-truncate" style="max-width:160px"
title="{{ dev.name }}">{{ dev.name or dev.device_id }}</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('sonoff.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.device_label }}
{% if dev.firmware %}<span class="ms-2 opacity-50">v{{ dev.firmware }}</span>{% endif %}
</div>
{# ── Switch/relay device ────────────────────────────────────── #}
{% if dev.kind in ('switch', 'light', 'fan') and dev.num_channels > 0 %}
<div class="d-flex flex-wrap gap-2">
{% for ch in dev.all_channel_states() %}
{% set ch_on = ch.state %}
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-2 py-1"
id="ch-{{ dev.device_id }}-{{ ch.channel }}"
style="min-width:min-content">
<span class="small text-muted">{{ ch.label }}</span>
<div class="d-flex gap-1">
<button
class="btn btn-sm px-2 py-0 sonoff-ch-btn {{ 'btn-success' if ch_on else 'btn-outline-success' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=dev.device_id, channel=ch.channel, action='on') }}"
data-device="{{ dev.device_id }}" data-ch="{{ ch.channel }}" data-target="on"
title="Turn ON"
>ON</button>
<button
class="btn btn-sm px-2 py-0 sonoff-ch-btn {{ 'btn-danger' if not ch_on else 'btn-outline-danger' }}"
data-url="{{ url_for('sonoff.control_channel', board_id=board.id, device_id=dev.device_id, channel=ch.channel, action='off') }}"
data-device="{{ dev.device_id }}" data-ch="{{ ch.channel }}" data-target="off"
title="Turn OFF"
>OFF</button>
</div>
</div>
{% endfor %}
</div>
{# ── Sensor device ──────────────────────────────────────────── #}
{% elif dev.kind == 'sensor' %}
<div class="d-flex flex-wrap gap-3">
{% if dev.temperature is not none %}
<div class="text-center">
<div class="fs-4 fw-bold text-info">{{ dev.temperature }}°C</div>
<div class="small text-muted">Temperature</div>
</div>
{% endif %}
{% if dev.humidity is not none %}
<div class="text-center">
<div class="fs-4 fw-bold text-primary">{{ dev.humidity }}%</div>
<div class="small text-muted">Humidity</div>
</div>
{% endif %}
</div>
{# ── Power meter ────────────────────────────────────────────── #}
{% if dev.has_power_meter %}
<div class="d-flex flex-wrap gap-3 mt-2">
{% if dev.power is not none %}
<div class="text-center">
<div class="fs-5 fw-bold text-warning">{{ dev.power }}W</div>
<div class="small text-muted">Power</div>
</div>
{% endif %}
{% if dev.voltage is not none %}
<div class="text-center">
<div class="fs-5 fw-bold text-success">{{ dev.voltage }}V</div>
<div class="small text-muted">Voltage</div>
</div>
{% endif %}
{% if dev.current is not none %}
<div class="text-center">
<div class="fs-5 fw-bold text-danger">{{ dev.current }}A</div>
<div class="small text-muted">Current</div>
</div>
{% endif %}
</div>
{% endif %}
{# ── RF Bridge / Remote ─────────────────────────────────────── #}
{% elif dev.kind == 'remote' %}
<div class="text-muted small"><i class="bi bi-broadcast me-1"></i>RF Bridge cloud only</div>
{% endif %}
<div class="text-muted mt-2" style="font-size:.7rem">
ID: {{ dev.device_id }}
{% if dev.ip_address %}
&nbsp;·&nbsp;<i class="bi bi-wifi"></i> {{ dev.ip_address }}
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-cloud-slash fs-1 d-block mb-3"></i>
<p class="fs-5">No devices found.</p>
{% if has_credentials %}
<form method="post" action="{{ url_for('sonoff.sync_devices', board_id=board.id) }}">
<button class="btn btn-primary">
<i class="bi bi-arrow-repeat me-1"></i>Sync from eWeLink
</button>
</form>
{% else %}
<a href="{{ url_for('sonoff.auth_settings', board_id=board.id) }}" class="btn btn-warning">
<i class="bi bi-key me-1"></i>Configure eWeLink Account
</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
const _socket = io();
// ── AJAX channel control ──────────────────────────────────────────────────────
document.querySelectorAll(".sonoff-ch-btn").forEach(btn => {
btn.addEventListener("click", async function() {
const url = this.dataset.url;
const did = this.dataset.device;
const ch = parseInt(this.dataset.ch);
const tgt = this.dataset.target; // "on" or "off"
this.disabled = true;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json",
"X-Requested-With": "XMLHttpRequest" },
});
const data = await resp.json();
if (data.ok) {
_applyChannelState(did, ch, data.state);
}
} catch(e) { console.error(e); }
finally { this.disabled = false; }
});
});
function _applyChannelState(did, ch, isOn) {
const container = document.getElementById(`ch-${did}-${ch}`);
if (!container) return;
const [onBtn, offBtn] = container.querySelectorAll(".sonoff-ch-btn");
if (isOn) {
onBtn.className = onBtn.className.replace("btn-outline-success", "btn-success");
offBtn.className = offBtn.className.replace("btn-danger", "btn-outline-danger");
} else {
onBtn.className = onBtn.className.replace("btn-success", "btn-outline-success");
offBtn.className = offBtn.className.replace("btn-outline-danger", "btn-danger");
}
}
// ── SocketIO live updates ─────────────────────────────────────────────────────
_socket.on("sonoff_update", function(data) {
if (data.board_id !== {{ board.id }}) return;
_applyChannelState(data.device_id, data.channel, data.state);
// Optional: flash the card border briefly
const card = document.getElementById("sonoff-card-" + data.device_id);
if (card) {
card.querySelector(".card").classList.add("border-info");
setTimeout(() => card.querySelector(".card").classList.remove("border-info"), 800);
}
});
</script>
{% endblock %}