Public Access
Rules can be limited to schedule blocks
Each rule has the blocks it acts in (none: always; without a running schedule always). Stored as a block mask (schedule format 2, version 1 records are converted), in JSON as the blocks' start times. Version 2.7.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
87e242bf89
commit
53e9057d02
11 files changed
+166
-19
No files matched your search
+1
-1
@@ -105,7 +105,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
|
||||
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
|
||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
||||
|
||||
#define FIRMWARE_VERSION "2.7.0"
|
||||
#define FIRMWARE_VERSION "2.7.1"
|
||||
|
||||
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -60,6 +60,8 @@ const char *validateSchedule(const PageSchedule &s)
|
||||
return "Rule times must be 1 to 3600 seconds";
|
||||
if (r.interval < RULE_INTERVAL_MIN || r.interval > RULE_INTERVAL_MAX)
|
||||
return "Rules are read every 2 to 600 seconds";
|
||||
if (r.blocks >> s.block_count)
|
||||
return "A rule is set for a block that doesn't exist";
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -79,6 +81,11 @@ static bool number(const char *text, double &value)
|
||||
return end != text && *end == '\0';
|
||||
}
|
||||
|
||||
bool ruleInBlock(const TriggerRule &rule, int block)
|
||||
{
|
||||
return rule.blocks == 0 || block < 0 || (rule.blocks >> block) & 1;
|
||||
}
|
||||
|
||||
bool ruleMatches(const TriggerRule &rule, const char *value)
|
||||
{
|
||||
if (value == nullptr)
|
||||
|
||||
@@ -63,6 +63,7 @@ struct TriggerRule
|
||||
TriggerMode mode = TriggerMode::WhileTrue;
|
||||
uint16_t seconds = 30; // AddToRotation: time in the rotation; ForSeconds: how long
|
||||
uint16_t interval = 10; // read the entity this often (s)
|
||||
uint8_t blocks = 0; // bit i: active during block i; 0: always (also without a schedule)
|
||||
};
|
||||
|
||||
struct PageSchedule
|
||||
@@ -78,6 +79,9 @@ struct PageSchedule
|
||||
// the pages exist.
|
||||
const char *validateSchedule(const PageSchedule &schedule);
|
||||
|
||||
// Whether a rule acts during this block (-1: no schedule runs, then always)
|
||||
bool ruleInBlock(const TriggerRule &rule, int block);
|
||||
|
||||
// Whether a rule's condition is true for this value (nullptr: not known, never true)
|
||||
bool ruleMatches(const TriggerRule &rule, const char *value);
|
||||
|
||||
|
||||
@@ -104,8 +104,9 @@ PageChoice PageSelector::update(int minute, uint32_t now)
|
||||
const TriggerRule &r = _schedule->rules[i];
|
||||
if (r.mode == TriggerMode::ForSeconds && _rule_timer[i] && now - _rule_since[i] >= r.seconds * 1000UL)
|
||||
_rule_timer[i] = false;
|
||||
bool takes_over = (r.mode == TriggerMode::WhileTrue && _rule_active[i]) ||
|
||||
(r.mode == TriggerMode::ForSeconds && _rule_timer[i]);
|
||||
bool takes_over = ((r.mode == TriggerMode::WhileTrue && _rule_active[i]) ||
|
||||
(r.mode == TriggerMode::ForSeconds && _rule_timer[i])) &&
|
||||
ruleInBlock(r, _block);
|
||||
if (takes_over)
|
||||
{
|
||||
choice.page = r.page;
|
||||
@@ -137,7 +138,7 @@ PageChoice PageSelector::update(int minute, uint32_t now)
|
||||
for (int i = 0; i < rule_count; i++)
|
||||
{
|
||||
const TriggerRule &r = _schedule->rules[i];
|
||||
if (r.mode == TriggerMode::AddToRotation && _rule_active[i])
|
||||
if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block))
|
||||
items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
#include "logic/page_schedule.h"
|
||||
|
||||
// Decides which page or bitmap is shown, from (highest priority first):
|
||||
// 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order
|
||||
// 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order; a rule can be
|
||||
// limited to some schedule blocks (TriggerRule::blocks)
|
||||
// 2. a manual pick (web UI, Home Assistant "Mode"), until the next schedule block starts
|
||||
// 3. the schedule: the pages of the current block rotate, plus the pages of active
|
||||
// AddToRotation rules
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
static const char *NAMESPACE = "flipdot";
|
||||
static const char *KEY = "schedule";
|
||||
// Increase when PageSchedule changes (older records are then ignored)
|
||||
static const uint16_t VERSION = 1;
|
||||
// Increase when PageSchedule changes; older records are ignored unless converted below
|
||||
static const uint16_t VERSION = 2;
|
||||
|
||||
struct StoredSchedule
|
||||
{
|
||||
@@ -15,18 +15,92 @@ struct StoredSchedule
|
||||
PageSchedule schedule;
|
||||
};
|
||||
|
||||
// Version 1: the rules had no blocks (they were always active). Frozen copy, converted.
|
||||
namespace v1
|
||||
{
|
||||
struct Rule
|
||||
{
|
||||
char entity[ELEMENT_ENTITY_SIZE];
|
||||
char attribute[ELEMENT_ATTRIBUTE_SIZE];
|
||||
Compare compare;
|
||||
char value[RULE_VALUE_SIZE];
|
||||
char page[PAGE_NAME_SIZE];
|
||||
TriggerMode mode;
|
||||
uint16_t seconds;
|
||||
uint16_t interval;
|
||||
};
|
||||
|
||||
struct Schedule
|
||||
{
|
||||
bool enabled;
|
||||
uint8_t block_count;
|
||||
ScheduleBlock blocks[SCHEDULE_BLOCKS];
|
||||
uint8_t rule_count;
|
||||
Rule rules[TRIGGER_RULES];
|
||||
};
|
||||
|
||||
struct Stored
|
||||
{
|
||||
uint16_t version;
|
||||
uint16_t size;
|
||||
Schedule schedule;
|
||||
};
|
||||
|
||||
static void convert(const Schedule &old, PageSchedule &s)
|
||||
{
|
||||
s = PageSchedule();
|
||||
s.enabled = old.enabled;
|
||||
s.block_count = old.block_count;
|
||||
memcpy(s.blocks, old.blocks, sizeof(s.blocks));
|
||||
s.rule_count = old.rule_count;
|
||||
for (int i = 0; i < TRIGGER_RULES; i++)
|
||||
{
|
||||
const Rule &o = old.rules[i];
|
||||
TriggerRule &r = s.rules[i];
|
||||
memcpy(r.entity, o.entity, sizeof(r.entity));
|
||||
memcpy(r.attribute, o.attribute, sizeof(r.attribute));
|
||||
r.compare = o.compare;
|
||||
memcpy(r.value, o.value, sizeof(r.value));
|
||||
memcpy(r.page, o.page, sizeof(r.page));
|
||||
r.mode = o.mode;
|
||||
r.seconds = o.seconds;
|
||||
r.interval = o.interval;
|
||||
}
|
||||
}
|
||||
} // namespace v1
|
||||
|
||||
static_assert(sizeof(v1::Stored) != sizeof(StoredSchedule), "the versions are told apart by their size");
|
||||
|
||||
void loadSchedule(PageSchedule &schedule)
|
||||
{
|
||||
schedule = PageSchedule();
|
||||
StoredSchedule *stored = new StoredSchedule;
|
||||
Preferences prefs;
|
||||
prefs.begin(NAMESPACE, true);
|
||||
size_t read = prefs.isKey(KEY) ? prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) : 0;
|
||||
size_t length = prefs.isKey(KEY) ? prefs.getBytesLength(KEY) : 0;
|
||||
if (length == sizeof(StoredSchedule))
|
||||
{
|
||||
StoredSchedule *stored = new StoredSchedule;
|
||||
if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) &&
|
||||
stored->version == VERSION && stored->size == sizeof(PageSchedule) &&
|
||||
validateSchedule(stored->schedule) == nullptr)
|
||||
schedule = stored->schedule;
|
||||
delete stored;
|
||||
}
|
||||
else if (length == sizeof(v1::Stored))
|
||||
{
|
||||
v1::Stored *stored = new v1::Stored;
|
||||
PageSchedule *converted = new PageSchedule;
|
||||
if (prefs.getBytes(KEY, stored, sizeof(v1::Stored)) == sizeof(v1::Stored) && stored->version == 1 &&
|
||||
stored->size == sizeof(v1::Schedule))
|
||||
{
|
||||
v1::convert(stored->schedule, *converted);
|
||||
if (validateSchedule(*converted) == nullptr)
|
||||
schedule = *converted;
|
||||
}
|
||||
delete converted;
|
||||
delete stored;
|
||||
}
|
||||
prefs.end();
|
||||
if (read == sizeof(StoredSchedule) && stored->version == VERSION && stored->size == sizeof(PageSchedule) &&
|
||||
validateSchedule(stored->schedule) == nullptr)
|
||||
schedule = stored->schedule;
|
||||
delete stored;
|
||||
}
|
||||
|
||||
void saveSchedule(const PageSchedule &schedule)
|
||||
|
||||
@@ -59,6 +59,13 @@ void scheduleToJson(const PageSchedule &s, JsonObject out)
|
||||
o["mode"] = MODES[(int)r.mode];
|
||||
o["seconds"] = r.seconds;
|
||||
o["interval"] = r.interval;
|
||||
JsonArray in_blocks = o["blocks"].to<JsonArray>();
|
||||
for (int b = 0; b < s.block_count; b++)
|
||||
if ((r.blocks >> b) & 1)
|
||||
{
|
||||
formatTime(s.blocks[b].start, time);
|
||||
in_blocks.add(String(time));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +111,19 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
|
||||
return "Rule text too long";
|
||||
r.seconds = constrain(o["seconds"] | 30, 0, 65535);
|
||||
r.interval = constrain(o["interval"] | 10, 0, 65535);
|
||||
// The blocks by their start time (none: always)
|
||||
for (JsonVariantConst start : o["blocks"].as<JsonArrayConst>())
|
||||
{
|
||||
uint16_t minutes;
|
||||
int found = -1;
|
||||
if (parseTime(start | "", minutes))
|
||||
for (int b = 0; b < s.block_count; b++)
|
||||
if (s.blocks[b].start == minutes)
|
||||
found = b;
|
||||
if (found < 0)
|
||||
return "A rule is set for a block that doesn't exist";
|
||||
r.blocks |= 1 << found;
|
||||
}
|
||||
}
|
||||
return validateSchedule(s);
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
// {enabled,
|
||||
// blocks: [{start: "07:00", effect: true, items: [{page, seconds}]}],
|
||||
// rules: [{entity, attribute, compare: "eq"|"ne"|"gt"|"lt", value, page,
|
||||
// mode: "while"|"rotate"|"for", seconds, interval}]}
|
||||
// mode: "while"|"rotate"|"for", seconds, interval,
|
||||
// blocks: ["07:00"] (the start times of the blocks it acts in; empty: always)}]}
|
||||
|
||||
void scheduleToJson(const PageSchedule &schedule, JsonObject out);
|
||||
// Returns nullptr or a message for the user. Validates (validateSchedule()).
|
||||
|
||||
@@ -98,7 +98,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div>
|
||||
<p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div>
|
||||
<div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div>
|
||||
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on.</p></div>
|
||||
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</p></div>
|
||||
<button class="b" onclick="scSave()">Save schedule and rules</button></section>
|
||||
|
||||
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
|
||||
@@ -267,7 +267,10 @@ function sinp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text
|
||||
function ssel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));if(!opts.some(x=>x[0]==o[k]))s.append(mk('option',{value:o[k],textContent:o[k]+' (gone)'}));
|
||||
s.value=o[k];s.onchange=()=>{o[k]=s.value;schedDirty=true;if(after)after()};return s}
|
||||
function pageOpts(){return pageList.map(p=>[p.name,p.name]).concat(bitmapList.map(b=>[b.name,b.name+' (bitmap)']))}
|
||||
function schedBuild(){if(!sched)return;$('sc_en').checked=sched.enabled;const d=$('sc_blocks'),opts=pageOpts();d.innerHTML='';
|
||||
function schedLink(){sched.rules.forEach(r=>{if(!r._b)r._b=(r.blocks||[]).map(t=>sched.blocks.find(b=>b.start==t)).filter(Boolean)})}
|
||||
function schedPayload(){return{enabled:sched.enabled,blocks:sched.blocks,rules:sched.rules.map(r=>{const o=Object.assign({},r);
|
||||
o.blocks=sched.blocks.filter(b=>(r._b||[]).includes(b)).map(b=>b.start);delete o._b;return o})}}
|
||||
function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enabled;const d=$('sc_blocks'),opts=pageOpts();d.innerHTML='';
|
||||
sched.blocks.forEach((b,i)=>{const t=sinp(b,'start','time');t.onchange=()=>{sched.blocks.sort(bySlot);schedDirty=true;schedBuild()};
|
||||
const ef=mk('input',{type:'checkbox',checked:b.effect});ef.onchange=()=>{b.effect=ef.checked;schedDirty=true};
|
||||
const box=mk('div',{className:'el'},mk('div',{className:'row'},mk('b',{textContent:'From'}),t,mk('label',{},ef,' switch with the refresh effect'),
|
||||
@@ -285,13 +288,16 @@ function schedBuild(){if(!sched)return;$('sc_en').checked=sched.enabled;const d=
|
||||
sinp(ru,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32}),ssel(ru,'compare',RCMP),sinp(ru,'value','text',{size:12,maxLength:32,placeholder:'printing'}),
|
||||
btn('Test',async()=>{const v=await api('/api/ha/value?entity='+encodeURIComponent(ru.entity)+'&attribute='+encodeURIComponent(ru.attribute));state.textContent='now: '+v.value})),
|
||||
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
|
||||
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d'))))});
|
||||
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')),
|
||||
sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)});
|
||||
c.onchange=()=>{ru._b=c.checked?ru._b.concat([b]):ru._b.filter(x=>x!==b);schedDirty=true};return mk('label',{},c,' '+b.start)}),
|
||||
mk('span',{className:'hint',textContent:'(none ticked: always)'})):''))});
|
||||
if(!sched.rules.length)rd.append(mk('p',{className:'hint',textContent:'No rules.'}))}
|
||||
function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return}
|
||||
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
|
||||
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
|
||||
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10});schedDirty=true;schedBuild()}
|
||||
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',sched);schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
|
||||
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
|
||||
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
|
||||
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
|
||||
// page editor: the ESP32 draws the preview and returns the element boxes
|
||||
|
||||
Reference in new issue
Block a user