The device watches the battery, grid and weather, can send Telegram and change inverter mode.
Rules are a short script (simplified JavaScript). File on the device: /automation.js, up to 6144 bytes (non-Latin letters take 2 bytes each; a counter sits under the editor).
Rules run every ~1.5 s, starting 20 seconds after boot.
edge/at state is not changed either).▶ running — rules are executing;
⛔ … — the script is stopped, with the reason and line number (same text as in the device log, code 16).
Save works even with an error (your edits are kept), but the rules do not run until the error is fixed.
Save also resets variables and the edge/every/held/at state.
fn setup() {
}
fn tick() {
if (edge(pid(100) < 50)) {
tg("Grid lost, battery [pid110]%");
}
}
pid(100) is grid voltage. [pid110] in the text is replaced with the live battery %. Any PID works the same way.
They edit the same file. Change one tab — you see it in the other after Save or switching tabs.
A card is one simple rule: “when … then do …”. The list shows the name and on/off. Click the name to open one card.
On a card you can:
// @vis Grid lost
if (edge(pid(100) < 50)) { tg("Grid lost"); }
// @endvis
A new Visual card writes these markers for you. Complex code (else, nested if, your own formulas) belongs in Script without @vis — then cards will not touch or break it.
Below the cards: “Show on home” (expose). Templates at the bottom are typical scenarios.
Variables, your own functions, if / else, night-tariff with two branches live here. Visual does not erase this code.
// Comment to end of line. The device ignores it.
let min_soc = 25; // global variable (kept between ticks)
fn setup() { // once after Save or reboot
expose(800, "Threshold", "%");
set(800, min_soc);
}
fn tick() { // every ~1.5 seconds
set(800, min_soc);
if (pid(110) < min_soc && every(600)) {
tg("Battery [pid110]%");
}
}
fn setup() — startup: show a value on the home page, initial set.fn tick() — the working heart. All “if battery…” rules go here.let — only at the top, not inside a function. Name up to 11 Latin characters: min_soc is OK, night_min_soc gives Check name>11.; after every call and assignment.if and else always need braces { } (in else if the if has its own). Without them Check fails.// comments (full lines and end-of-line) are ignored by the device; line numbers in errors match the editor.There is no for / while on purpose: the script always finishes and cannot hang.
The most common beginner mistake: if (pid(110) < 30) { tg("..."); } — Telegram floods every 1.5 s. Pick a trigger type:
| I want | Write | In plain words |
|---|---|---|
| Once, when it happens | if (edge(COND)) { ... } |
Fires on the false→true transition. After reboot, already-true does not fire again. |
| Remind while the condition holds | if (COND && every(600)) { ... } |
Every 600 s (10 min) while COND is true. Put every on the right. The first call only arms the timer (still false) — the first fire is after N seconds. |
| Wait until it has held for N seconds | if (held(COND, 300)) { ... } |
Becomes true and stays true every tick while COND holds. For a one-shot action: edge(held(…, 300)). Resets as soon as COND is false. |
| Exactly at 23:00, once a day | if (at("23:00")) { ... } |
Once per calendar day in that minute. After a reboot in the same minute it can fire again. |
| All the time while true | if (COND) { ... } |
Every tick. Almost never what you want for Telegram / inverter writes. |
if (edge(pid(100) < 50)) { tg("Grid lost"); }
Maximum — several “when”s in one tick:
if (edge(pid(100) < 50)) { tg("Grid lost, SOC [pid110]%"); }
if (edge(pid(100) > 50)) { tg("Grid [pid100]V"); }
if (pid(110) < 25 && every(600)) { tg("Low SOC [pid110]%"); }
if (pid(509) > 40 && every(1800)) { tg("Hot [pid509]°C"); }
Comparisons: > < >= <= == !=. Logic: && (and), || (or), ! (not). Parentheses ( ) control order.
You can mix PIDs: pid(106) - pid(117) > 1500 (PV surplus).
if without edge/every runs every ~1.5 s. Telegram in that branch would flood the chat (and you still only get 1 message per tick). Below is branch syntax; in real rules add edge or every.
if (pid(110) < 20) {
set(800, 1);
} else {
set(800, 0);
}
set(800, …) on a home-page expose value is safe every tick (RAM, not an inverter command). Do not do that to pid132.
The language understands else if. Branches are checked top to bottom; the first true one runs.
if (pid(110) < 15) {
set(800, 1);
} else if (pid(110) < 30) {
set(800, 2);
} else if (pid(110) > 90) {
set(800, 3);
} else {
set(800, 0);
}
if (at("23:00")) {
if (pid(513) >= 3000 && pid(110) >= 25) {
set(132, 2);
} else {
set(132, 1);
set(131, 2);
tg("Night charge, tomorrow [pid513]");
}
}
at it is safer to ask “is it 23:00?” first, then weather / SOC inside.
Do not write at("23:00") && pid(513) < 1000 the other way around: if the forecast is not yet “that”, today’s shot is already spent.
cond ? if_true : if_false
let flag = 0; // at the top of the file, not inside fn
fn tick() {
flag = pid(509) > 40 ? 1 : 0;
if (edge(flag == 1)) { tg("Heat [pid509]°C"); }
}
Several lines inside braces run in order.
| Call | What it does |
|---|---|
set(132, 2) | Write a parameter (inverter mode, GPIO, Tasmota, web button). For dropdowns — the option index, not amps: see “PID list”. If the parameter already holds that value the write is skipped (buttons always fire). After a failed inverter write the remaining set calls of that tick are skipped so data reading is not stalled. |
tg("text [pid110]%") | Telegram. One per tick. If the subscription expired — not sent. |
log("...") | Device log line (code 12). Up to 3 per tick, text up to 64 characters. |
http("http://192.168.0.4/cm?cmnd=Power+1") | LAN GET, ~1.5 s timeout, the response is not read. One per tick. Space in URL → +. [pid110] in the URL is substituted like in tg. A request to the device’s own IP is ignored. |
if (at("07:00")) { set(132, 2); }
Maximum — several actions together:
if (pid(513) < 1000 && at("23:00")) {
set(132, 1);
set(138, 4);
tg("Night charge, tomorrow [pid513] Wh/m², SOC [pid110]%");
log("night charge");
}
set on inverter PIDs (priorities, currents) really changes settings. Check first, then Save. For a dry run use the buzzer PID (often 123) or a home-page variable (800–807).
Device clock (NTP). now() is an integer HHMM: 7:30 → 730, 23:00 → 2300.
| Function | Example | When true |
|---|---|---|
between("07:00", "21:00") | day | From 07:00 to 21:00 inclusive |
between("22:00", "06:00") | night | Across midnight: 22:00…00:00…06:00 |
at("23:00") | moment | Once a day in that minute (after a reboot in the same minute — again) |
hm("07:30") | → 730 | For now() >= hm("07:00") && now() < hm("21:00") |
dow() | 0…6 | 0 = Sunday, 6 = Saturday |
if ((dow() == 0 || dow() == 6) && at("10:00")) {
tg("Weekend, SOC [pid110]%");
}
Do not parse pid611 in the script — use now / at / between. Until the clock is synced (right after a reboot without internet) now() = 9999 and between / at are false, so night rules do not fire by mistake.
Up to 16. They live between ticks and reset on reboot and when you save the script. They are not written to flash.
let peak = 0;
fn tick() {
peak = max(peak, pid(106));
if (at("23:50")) { peak = 0; }
}
PID number 800–807 (or a free one). Not stored in daily charts. A DS18B20 sensor may already use 800 and up — pick the next free number. An expose value can be edited from the home page, the inverter page, or Telegram: that write stays in RAM, no Modbus. The next set in the script overwrites it.
fn setup() {
expose(800, "Peak PV", "W");
}
fn tick() {
set(800, peak);
}
In Visual the same block sits under the cards.
Up to 8 functions including setup and tick (so up to 6 of your own). No parameters, name up to 11 characters, calls nest up to 4 deep (Check: depth). return is the result.
fn excess() {
return pid(106) - pid(117);
}
fn tick() {
if (edge(held(excess() > 1500 && pid(110) > 80, 300))) {
set(1000, 1);
}
}
return; inside tick skips the rest of the rules for that cycle.
| Function | Purpose |
|---|---|
pid(n) | Current value. Empty → -1. |
set(n, v) | Write a parameter. |
tg("...") log("...") http("...") | Telegram / log / HTTP GET. |
now() hm("HH:MM") | Time as HHMM. |
between("A","B") | In range; if A > B — across midnight. |
at("HH:MM") | Once per calendar day in that minute. After a reboot in the same minute it can fire again. |
dow() | Weekday 0…6. |
edge(x) every(sec) held(x, sec) | Rising edge / period / hold. State is tied to the place of the call in the text. |
changed(x) | 1 when the number changes (0.001). The first cycle after Save is 0, so boot does not fire. |
cooldown(sec) | 1 immediately, then 0 until N seconds pass. Unlike every, the first tick is not silent. |
hyst(val, on, off) | Hysteresis. If on ≥ off: turn on when val ≥ on, off when val ≤ off, keep the previous state in the gap. |
sunrise() sunset() | Sunrise/sunset as HHMM from weather. No forecast → 9999 (will not fire at 00:00). |
avg(pid, sec) | Smoothed PID (fewer false triggers). |
min(a,b) max(a,b) abs(x) | Math. |
expose(pid,"name","unit") | Home-page value. Up to 8. |
Also: + - * / %, comparisons, && || !, ?:. == has a 0.001 tolerance (25 and 25.0005 count as equal).
Grid lost / restored
if (edge(pid(100) < 50)) {
tg("Grid lost! Battery [pid110]% ([pid109]V)");
}
if (edge(pid(100) > 50)) {
tg("Grid [pid100]V");
}
Two separate ifs, not else if: otherwise the second edge does not update while the first fired, and “grid restored” can be missed.
Night on battery if SOC is high
if (edge(between("22:00", "06:00") && pid(110) > 60)) {
set(132, 2);
tg("Night, battery [pid110]%");
}
PV surplus for 5 min → Tasmota socket (pid1000)
if (edge(held(pid(106) - pid(117) > 1500 && pid(110) > 80, 300))) {
set(1000, 1);
}
if (edge(pid(110) < 40 || pid(106) < 500)) {
set(1000, 0);
}
On its own held stays true every tick — without edge, set would hammer the relay every 1.5 s.
GPIO: light at 18:00 / 22:00 (pin mode is set in the parameter settings)
if (at("18:00")) { set(704, 1); }
if (at("22:00")) { set(704, 0); }
More complex: night charge from forecast + morning SBU
Write this in Script without @vis. Thresholds and pid131/132/138 indexes depend on your inverter — see “PID list”.
let sun_ok = 3000;
let min_soc = 25;
let grid_a = 4;
fn tick() {
if (at("23:00")) {
if (pid(513) >= sun_ok && pid(110) >= min_soc) {
set(131, 3);
set(132, 2);
} else {
set(132, 1);
set(131, 2);
set(138, grid_a);
tg("Night charge. Tomorrow [pid513], SOC [pid110]%");
}
}
if (at("07:00")) {
set(132, 2);
set(131, 1);
}
}
pid513 is tomorrow’s solar forecast (Wh/m²). pid506 is “is it sunny now” — not for tomorrow.
Low SOC without spam
if (pid(110) < 25 && cooldown(1800)) {
tg("Battery [pid110]%");
}
SOC hysteresis (Script tab, because of else)
if (hyst(pid(110), 25, 35)) { set(132, 1); }
else { set(132, 2); }
Here on < off: turn on when SOC ≤ 25, off when SOC ≥ 35. Between 25 and 35 the previous state stays.
Light from sunrise to sunset
if (between(sunrise(), sunset())) { set(704, 1); }
Until weather is loaded, sunrise() and sunset() return 9999 and the condition stays false.
tg and comments take 2 bytes each; counter under the editor). 16 × let, 8 × fn (including setup/tick), 8 × expose, 8 × avg, 32 slots for edge/every/held/at/changed/cooldown/hyst._, up to 11 characters.tg/log/http/at/…tg run in one cycle — only the first is sent. Also at most 12 tg per hour; extras are dropped quietly.now, tg counter) and Automation log lines. The previous save is /automation.bak (Restore previous).warn: bare if+tg/set does not block Save: a bare if with tg or an inverter write runs every ~1.5 s. set on 800–807 is not warned.edge/at call is a separate place in the text. Do not copy “the same” call twice thinking it is one counter.| Word | What is wrong |
|---|---|
{ } ( ) ; , = : | That character was expected on the given line (unclosed bracket, missing ;). |
undef | Unknown variable: no let for it (or a typo in the name). |
unknown fn, need ( | Unknown function / function used without (). |
str | Quoted text is required here (at("23:00"), expose(800, "Name", "unit")). |
name>11 | Variable/function name longer than 11 characters. |
stmt, top, prim, bad char | Unexpected text: only let and fn at top level; inside functions — if, assignment, call, return. |
nested let/fn, no loops | let/fn inside a function; for/while are not supported. |
lets, fns, slots, avg, depth | Limit exceeded: 16 variables, 8 functions, 24 edge/every/held/at, 8 avg, 4 nested calls. |
too long, too big, nomem, load failed | Script over 6144 bytes / out of memory / file could not be read. |
busy, retry | The device is in the middle of a tick (e.g. waiting for the inverter) — press again. |
stopped: 3 crashes | The device crash-rebooted three times in a row — automation was disabled as a safeguard. Review the script and press Save (or restart the device from Settings). |
Runtime errors (e.g. undef inside a rule) show up in the same status line above the buttons and are written to the device log (Inverter page, code 16) once — until the next Save. Rules above the failing line keep working.
if, use edge or every.held / avg.pid(n) can be -1 (not read yet).⛔ with a reason means the script is not running at all.edge rule “did not fire” right after Save → the condition was already true at Save time; edge waits for a false→true transition. every(N) fires the first time after N seconds.@vis/@endvis around it, leave it in Script only.Full parameter list — the PID list button on the automation page. GPIO 7xx, Tasmota 1000+, BMS 9xx — as in your table.
If /automation_PARAMS.json exists and /automation.js does not yet — the device converts the rules itself (offline). JSON stays as a copy. Old rules become cards with @vis.
Firmware 4.24+. Check → Save. Complex rules — in Script without cards; simple ones — in Visual with @vis.