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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
104
app/templates/sonoff/auth_settings.html
Normal file
104
app/templates/sonoff/auth_settings.html
Normal 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 %}
|
||||
281
app/templates/sonoff/device.html
Normal file
281
app/templates/sonoff/device.html
Normal 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 %} · v{{ device.firmware }}{% endif %}
|
||||
{% if device.model %} · {{ 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 %}
|
||||
260
app/templates/sonoff/gateway.html
Normal file
260
app/templates/sonoff/gateway.html
Normal 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 %}
|
||||
· <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 %}
|
||||
Reference in New Issue
Block a user