Add an actions box showing why each switch changed
This commit is contained in:
1 parent
a28db4d1c9
commit
6c43256088
3 files changed
+338
-8
No files matched your search
@@ -663,6 +663,51 @@ def cmd_status(args):
|
|||||||
print("stopped")
|
print("stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def record_manual_event(profile_path, target, desired):
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .profiles import load_yaml as _load
|
||||||
|
|
||||||
|
try:
|
||||||
|
identity = (_load(profile_path).get("identity") or {})
|
||||||
|
except Exception:
|
||||||
|
identity = {}
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"epoch": time.time(),
|
||||||
|
"when": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"state": bool(desired),
|
||||||
|
"cause": "manual",
|
||||||
|
"profile": None,
|
||||||
|
"rule": None,
|
||||||
|
"condition": None,
|
||||||
|
"reason": "switched manually from the command line",
|
||||||
|
"target": identity.get("name") or identity.get("model") or target.host,
|
||||||
|
"target_channel": target.channel,
|
||||||
|
"source": None,
|
||||||
|
"source_serial": None,
|
||||||
|
"values": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
path = paths.STATE_DIR / "events-manual.json"
|
||||||
|
try:
|
||||||
|
paths.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
history = []
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
history = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
history = []
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
history.append(event)
|
||||||
|
path.write_text(json.dumps(history[-200:]), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def cmd_switch(args):
|
def cmd_switch(args):
|
||||||
from .shelly import ShellyTarget
|
from .shelly import ShellyTarget
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -704,6 +749,8 @@ def cmd_switch(args):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
fail(f"{type(err).__name__}: {err}")
|
fail(f"{type(err).__name__}: {err}")
|
||||||
|
|
||||||
|
record_manual_event(path, target, desired)
|
||||||
|
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
confirmed = await target.get_state(session)
|
confirmed = await target.get_state(session)
|
||||||
if confirmed is None:
|
if confirmed is None:
|
||||||
|
|||||||
+79
-4
@@ -8,12 +8,15 @@ from datetime import datetime
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from . import paths
|
from . import paths
|
||||||
from .profiles import load_yaml
|
from .profiles import load_yaml, slugify
|
||||||
from .notify import Notifier, render
|
from .notify import Notifier, render
|
||||||
from .rules import derived_values, format_duration, validate
|
from .rules import derived_values, format_duration, validate
|
||||||
from .shelly import ShellyTarget
|
from .shelly import ShellyTarget
|
||||||
|
|
||||||
|
|
||||||
|
EVENT_HISTORY = 200
|
||||||
|
|
||||||
|
|
||||||
async def interruptible_sleep(seconds):
|
async def interruptible_sleep(seconds):
|
||||||
remaining = float(seconds or 0)
|
remaining = float(seconds or 0)
|
||||||
while remaining > 0:
|
while remaining > 0:
|
||||||
@@ -150,7 +153,15 @@ class Engine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def apply(
|
async def apply(
|
||||||
self, session, desired, reason, now, rule=None, variables=None, force=False
|
self,
|
||||||
|
session,
|
||||||
|
desired,
|
||||||
|
reason,
|
||||||
|
now,
|
||||||
|
rule=None,
|
||||||
|
variables=None,
|
||||||
|
force=False,
|
||||||
|
cause="rule",
|
||||||
):
|
):
|
||||||
if self.last_commanded is desired:
|
if self.last_commanded is desired:
|
||||||
return False
|
return False
|
||||||
@@ -176,6 +187,7 @@ class Engine:
|
|||||||
self.recent_actions.append(now)
|
self.recent_actions.append(now)
|
||||||
self.report(f"turned {self._word(desired)} - {reason}")
|
self.report(f"turned {self._word(desired)} - {reason}")
|
||||||
self.save_state(desired, reason)
|
self.save_state(desired, reason)
|
||||||
|
self.record_event(desired, reason, rule, variables, cause=cause)
|
||||||
await self.notify(desired, rule, variables, event="action")
|
await self.notify(desired, rule, variables, event="action")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -341,6 +353,7 @@ class Engine:
|
|||||||
"restored after telemetry recovered",
|
"restored after telemetry recovered",
|
||||||
now,
|
now,
|
||||||
force=True,
|
force=True,
|
||||||
|
cause="recovered",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def check_floor(self, session, variables, now):
|
async def check_floor(self, session, variables, now):
|
||||||
@@ -377,6 +390,7 @@ class Engine:
|
|||||||
now,
|
now,
|
||||||
variables=variables,
|
variables=variables,
|
||||||
force=True,
|
force=True,
|
||||||
|
cause="floor",
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -404,6 +418,7 @@ class Engine:
|
|||||||
now,
|
now,
|
||||||
variables=variables,
|
variables=variables,
|
||||||
force=True,
|
force=True,
|
||||||
|
cause="floor",
|
||||||
)
|
)
|
||||||
|
|
||||||
if floor.notify:
|
if floor.notify:
|
||||||
@@ -445,6 +460,58 @@ class Engine:
|
|||||||
force=True,
|
force=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def record_event(self, desired, reason, rule, variables, cause="rule"):
|
||||||
|
source_identity = self.anker_profile.get("identity", {})
|
||||||
|
target_identity = self.target.profile.get("identity", {})
|
||||||
|
|
||||||
|
detail = {}
|
||||||
|
for key in (
|
||||||
|
"battery_soc",
|
||||||
|
"pv_total",
|
||||||
|
"pv_surplus",
|
||||||
|
"output_power_total",
|
||||||
|
"ac_input_power",
|
||||||
|
):
|
||||||
|
value = (variables or {}).get(key)
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
detail[key] = round(value)
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"epoch": time.time(),
|
||||||
|
"when": stamp(),
|
||||||
|
"state": bool(desired),
|
||||||
|
"cause": cause,
|
||||||
|
"profile": self.profile.name,
|
||||||
|
"rule": rule.name if rule else None,
|
||||||
|
"condition": rule.when_source if rule else None,
|
||||||
|
"reason": reason,
|
||||||
|
"target": target_identity.get("name")
|
||||||
|
or target_identity.get("model")
|
||||||
|
or self.target.host,
|
||||||
|
"target_channel": self.target.channel,
|
||||||
|
"source": source_identity.get("name")
|
||||||
|
or source_identity.get("model")
|
||||||
|
or source_identity.get("serial"),
|
||||||
|
"source_serial": source_identity.get("serial"),
|
||||||
|
"values": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
path = paths.STATE_DIR / f"events-{slugify(self.profile.name)}.json"
|
||||||
|
try:
|
||||||
|
paths.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
history = []
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
history = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
history = []
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
history.append(event)
|
||||||
|
path.write_text(json.dumps(history[-EVENT_HISTORY:]), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def publish_live(self, variables, age):
|
def publish_live(self, variables, age):
|
||||||
identity = self.anker_profile.get("identity", {})
|
identity = self.anker_profile.get("identity", {})
|
||||||
serial = identity.get("serial") or "unknown"
|
serial = identity.get("serial") or "unknown"
|
||||||
@@ -531,7 +598,11 @@ class Engine:
|
|||||||
self.pre_stale_state = self.last_commanded
|
self.pre_stale_state = self.last_commanded
|
||||||
self.stale_action_state = self.profile.safe_state
|
self.stale_action_state = self.profile.safe_state
|
||||||
await self.apply(
|
await self.apply(
|
||||||
session, self.profile.safe_state, "mqtt disconnected safe state", now
|
session,
|
||||||
|
self.profile.safe_state,
|
||||||
|
"connection to Anker lost",
|
||||||
|
now,
|
||||||
|
cause="stale",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self.stale_notified:
|
if not self.stale_notified:
|
||||||
@@ -597,7 +668,11 @@ class Engine:
|
|||||||
self.pre_stale_state = self.last_commanded
|
self.pre_stale_state = self.last_commanded
|
||||||
self.stale_action_state = self.profile.safe_state
|
self.stale_action_state = self.profile.safe_state
|
||||||
await self.apply(
|
await self.apply(
|
||||||
session, self.profile.safe_state, "stale telemetry safe state", now
|
session,
|
||||||
|
self.profile.safe_state,
|
||||||
|
"telemetry went stale",
|
||||||
|
now,
|
||||||
|
cause="stale",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+212
-4
@@ -17,7 +17,14 @@ class Sampler:
|
|||||||
def __init__(self, interval=5.0, history=HISTORY_SIZE):
|
def __init__(self, interval=5.0, history=HISTORY_SIZE):
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self.history = deque(maxlen=history)
|
self.history = deque(maxlen=history)
|
||||||
self.snapshot = {"anker": [], "shelly": [], "updated": None, "engine": False}
|
self.snapshot = {
|
||||||
|
"anker": [],
|
||||||
|
"shelly": [],
|
||||||
|
"events": [],
|
||||||
|
"multi": False,
|
||||||
|
"updated": None,
|
||||||
|
"engine": False,
|
||||||
|
}
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.stop_event = threading.Event()
|
self.stop_event = threading.Event()
|
||||||
self.thread = None
|
self.thread = None
|
||||||
@@ -196,9 +203,26 @@ class Sampler:
|
|||||||
)
|
)
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
def _events(self):
|
||||||
|
events = []
|
||||||
|
if not paths.STATE_DIR.exists():
|
||||||
|
return events
|
||||||
|
|
||||||
|
for path in sorted(paths.STATE_DIR.glob("events-*.json")):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(data, list):
|
||||||
|
events.extend(entry for entry in data if isinstance(entry, dict))
|
||||||
|
|
||||||
|
events.sort(key=lambda entry: entry.get("epoch", 0), reverse=True)
|
||||||
|
return events[:60]
|
||||||
|
|
||||||
def _sample(self):
|
def _sample(self):
|
||||||
anker = self._anker_devices()
|
anker = self._anker_devices()
|
||||||
shelly = self._shelly_devices()
|
shelly = self._shelly_devices()
|
||||||
|
events = self._events()
|
||||||
|
|
||||||
point = {"t": time.time()}
|
point = {"t": time.time()}
|
||||||
for device in anker:
|
for device in anker:
|
||||||
@@ -216,6 +240,8 @@ class Sampler:
|
|||||||
self.snapshot = {
|
self.snapshot = {
|
||||||
"anker": anker,
|
"anker": anker,
|
||||||
"shelly": shelly,
|
"shelly": shelly,
|
||||||
|
"events": events,
|
||||||
|
"multi": len(anker) > 1 or len(shelly) > 1,
|
||||||
"updated": time.strftime("%H:%M:%S"),
|
"updated": time.strftime("%H:%M:%S"),
|
||||||
"engine": any(d["live"] for d in anker),
|
"engine": any(d["live"] for d in anker),
|
||||||
"interval": self.interval,
|
"interval": self.interval,
|
||||||
@@ -558,6 +584,106 @@ PAGE = """<!doctype html>
|
|||||||
|
|
||||||
canvas { width: 100%; height: 300px; display: block; }
|
canvas { width: 100%; height: 300px; display: block; }
|
||||||
|
|
||||||
|
.event-scroll {
|
||||||
|
max-height: 232px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--rule) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-scroll::-webkit-scrollbar { width: 9px; }
|
||||||
|
.event-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.event-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--rule);
|
||||||
|
border: 2px solid var(--panel-raised);
|
||||||
|
}
|
||||||
|
.event-scroll::-webkit-scrollbar-thumb:hover { background: var(--ink-soft); }
|
||||||
|
|
||||||
|
.event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 12px 9px 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event:first-child { border-top: none; }
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-state {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1px 0;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
flex: 0 0 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-state.on { color: var(--charge); }
|
||||||
|
.event-state.off { color: var(--ink-soft); }
|
||||||
|
|
||||||
|
.event-target {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 20ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-why {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-cause {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
margin-right: 7px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-cause.floor { color: var(--alert); border-color: currentColor; }
|
||||||
|
.event-cause.stale { color: var(--solar); border-color: currentColor; }
|
||||||
|
|
||||||
|
.event-detail {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-day {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 14px 0 6px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-day:first-child { border-top: none; padding-top: 0; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -586,18 +712,23 @@ PAGE = """<!doctype html>
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Anker SOLIX</h2>
|
<h2>Anker devices</h2>
|
||||||
<div id="anker"></div>
|
<div id="anker"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Shelly switches</h2>
|
<h2>Shelly devices</h2>
|
||||||
<div id="shelly"></div>
|
<div id="shelly"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Actions</h2>
|
||||||
|
<div class="event-scroll"><div id="events"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="chart-head">
|
<div class="chart-head">
|
||||||
<h2 style="margin:0">Recent history</h2>
|
<h2 style="margin:0">History</h2>
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span><i class="swatch sw-solar"></i>solar W</span>
|
<span><i class="swatch sw-solar"></i>solar W</span>
|
||||||
<span><i class="swatch sw-grid"></i>grid in W</span>
|
<span><i class="swatch sw-grid"></i>grid in W</span>
|
||||||
@@ -806,6 +937,82 @@ function renderShelly(devices) {
|
|||||||
+ '</tbody></table>';
|
+ '</tbody></table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CAUSE_LABEL = {
|
||||||
|
rule: 'rule',
|
||||||
|
floor: 'safety floor',
|
||||||
|
stale: 'telemetry lost',
|
||||||
|
recovered: 'recovered',
|
||||||
|
manual: 'manual'
|
||||||
|
};
|
||||||
|
|
||||||
|
function dayLabel(epoch) {
|
||||||
|
const d = new Date(epoch * 1000);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today.getTime() - 86400000);
|
||||||
|
const same = (a, b) => a.toDateString() === b.toDateString();
|
||||||
|
if (same(d, today)) return 'Today';
|
||||||
|
if (same(d, yesterday)) return 'Yesterday';
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clockLabel(epoch) {
|
||||||
|
const d = new Date(epoch * 1000);
|
||||||
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDetail(e) {
|
||||||
|
const v = e.values || {};
|
||||||
|
const bits = [];
|
||||||
|
if (v.battery_soc !== undefined) bits.push('battery ' + v.battery_soc + '%');
|
||||||
|
if (v.pv_total !== undefined) bits.push('solar ' + v.pv_total + 'W');
|
||||||
|
if (v.output_power_total !== undefined) bits.push('load ' + v.output_power_total + 'W');
|
||||||
|
if (v.ac_input_power) bits.push('grid ' + v.ac_input_power + 'W');
|
||||||
|
if (!bits.length) return '';
|
||||||
|
const who = e.source ? esc(e.source) + ': ' : '';
|
||||||
|
return who + bits.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvents(events, multi) {
|
||||||
|
const box = document.getElementById('events');
|
||||||
|
|
||||||
|
if (!events || !events.length) {
|
||||||
|
box.innerHTML = '<p class="empty">Every change to a plug will appear here '
|
||||||
|
+ 'with the reason for it.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let lastDay = null;
|
||||||
|
|
||||||
|
events.forEach(e => {
|
||||||
|
const day = dayLabel(e.epoch);
|
||||||
|
if (day !== lastDay) {
|
||||||
|
html += '<div class="event-day">' + day + '</div>';
|
||||||
|
lastDay = day;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cause = e.cause || 'rule';
|
||||||
|
const label = CAUSE_LABEL[cause] || cause;
|
||||||
|
const why = e.rule ? esc(e.rule) : esc(e.reason || 'changed');
|
||||||
|
const detail = eventDetail(e);
|
||||||
|
const tip = [e.condition, detail].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
|
html += '<div class="event"' + (tip ? ' title="' + esc(tip) + '"' : '') + '>'
|
||||||
|
+ '<div class="event-time">' + clockLabel(e.epoch) + '</div>'
|
||||||
|
+ '<div class="event-state ' + (e.state ? 'on' : 'off') + '">'
|
||||||
|
+ (e.state ? 'ON' : 'OFF') + '</div>'
|
||||||
|
+ '<div class="event-target">' + esc(e.target || 'switch') + '</div>'
|
||||||
|
+ '<div class="event-why">'
|
||||||
|
+ '<span class="event-cause ' + cause + '">' + label + '</span>'
|
||||||
|
+ why
|
||||||
|
+ '</div>'
|
||||||
|
+ (detail ? '<div class="event-detail">' + detail + '</div>' : '')
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
box.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function drawChart(history, devices) {
|
function drawChart(history, devices) {
|
||||||
const canvas = document.getElementById('chart');
|
const canvas = document.getElementById('chart');
|
||||||
const ratio = window.devicePixelRatio || 1;
|
const ratio = window.devicePixelRatio || 1;
|
||||||
@@ -928,6 +1135,7 @@ async function refresh() {
|
|||||||
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
|
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
|
||||||
renderAnker(data.anker);
|
renderAnker(data.anker);
|
||||||
renderShelly(data.shelly);
|
renderShelly(data.shelly);
|
||||||
|
renderEvents(data.events, data.multi);
|
||||||
drawChart(data.history, data.anker);
|
drawChart(data.history, data.anker);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failures++;
|
failures++;
|
||||||
|
|||||||
Reference in new issue
Block a user