hassl 0.3.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hassl/__init__.py +1 -1
- hassl/ast/nodes.py +35 -2
- hassl/codegen/package.py +353 -13
- hassl/codegen/rules_min.py +50 -13
- hassl/parser/hassl.lark +48 -2
- hassl/parser/transform.py +355 -13
- hassl/semantics/analyzer.py +129 -13
- {hassl-0.3.0.dist-info → hassl-0.3.1.dist-info}/METADATA +76 -6
- hassl-0.3.1.dist-info/RECORD +23 -0
- hassl-0.3.0.dist-info/RECORD +0 -23
- {hassl-0.3.0.dist-info → hassl-0.3.1.dist-info}/WHEEL +0 -0
- {hassl-0.3.0.dist-info → hassl-0.3.1.dist-info}/entry_points.txt +0 -0
- {hassl-0.3.0.dist-info → hassl-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {hassl-0.3.0.dist-info → hassl-0.3.1.dist-info}/top_level.txt +0 -0
hassl/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.3.
|
|
1
|
+
__version__ = "0.3.1"
|
hassl/ast/nodes.py
CHANGED
|
@@ -19,11 +19,43 @@ class IfClause:
|
|
|
19
19
|
condition: Dict[str, Any]
|
|
20
20
|
actions: List[Dict[str, Any]]
|
|
21
21
|
|
|
22
|
+
# ---- NEW: Holiday sets & structured schedule windows ----
|
|
23
|
+
@dataclass
|
|
24
|
+
class HolidaySet:
|
|
25
|
+
id: str
|
|
26
|
+
country: str
|
|
27
|
+
province: Optional[str] = None
|
|
28
|
+
add: List[str] = field(default_factory=list) # YYYY-MM-DD
|
|
29
|
+
remove: List[str] = field(default_factory=list)
|
|
30
|
+
workdays: List[str] = field(default_factory=lambda: ["mon","tue","wed","thu","fri"])
|
|
31
|
+
excludes: List[str] = field(default_factory=lambda: ["sat","sun","holiday"])
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PeriodSelector:
|
|
35
|
+
# kind = 'months' | 'dates' | 'range'
|
|
36
|
+
kind: str
|
|
37
|
+
# data:
|
|
38
|
+
# - months: {"list":[Mon,...]} or {"range":[Mon,Mon]}
|
|
39
|
+
# - dates: {"start":"MM-DD","end":"MM-DD"}
|
|
40
|
+
# - range: {"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}
|
|
41
|
+
data: Dict[str, Any]
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ScheduleWindow:
|
|
45
|
+
start: str # "HH:MM"
|
|
46
|
+
end: str # "HH:MM"
|
|
47
|
+
day_selector: str # "weekdays" | "weekends" | "daily"
|
|
48
|
+
period: Optional[PeriodSelector] = None
|
|
49
|
+
holiday_ref: Optional[str] = None # id from HolidaySet (for 'except'/'only')
|
|
50
|
+
holiday_mode: Optional[str] = None # "except" | "only" | None
|
|
51
|
+
|
|
22
52
|
@dataclass
|
|
23
53
|
class Schedule:
|
|
24
54
|
name: str
|
|
25
|
-
# raw clauses as produced by the transformer
|
|
55
|
+
# raw clauses as produced by the transformer (legacy form)
|
|
26
56
|
clauses: List[Dict[str, Any]]
|
|
57
|
+
# structured windows for the new 'on ...' syntax (optional)
|
|
58
|
+
windows: List[ScheduleWindow] = field(default_factory=list)
|
|
27
59
|
private: bool = False
|
|
28
60
|
|
|
29
61
|
@dataclass
|
|
@@ -42,7 +74,8 @@ class Program:
|
|
|
42
74
|
imports: List[Dict[str, Any]] = field(default_factory=list)
|
|
43
75
|
def to_dict(self):
|
|
44
76
|
def enc(x):
|
|
45
|
-
if isinstance(x, (Alias, Sync, Rule, IfClause, Schedule
|
|
77
|
+
if isinstance(x, (Alias, Sync, Rule, IfClause, Schedule,
|
|
78
|
+
HolidaySet, ScheduleWindow, PeriodSelector)):
|
|
46
79
|
d = asdict(x); d["type"] = x.__class__.__name__; return d
|
|
47
80
|
return x
|
|
48
81
|
return {
|
hassl/codegen/package.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Dict, List, Iterable, Any
|
|
1
|
+
from typing import Dict, List, Iterable, Any, Tuple, Optional
|
|
2
2
|
import os, re
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from ..semantics.analyzer import IRProgram, IRSync
|
|
@@ -78,6 +78,13 @@ def _context_entity(entity: str, prop: str = None) -> str:
|
|
|
78
78
|
def _domain(entity: str) -> str:
|
|
79
79
|
return entity.split(".", 1)[0]
|
|
80
80
|
|
|
81
|
+
def _gate_entity_for_schedule(resolved: str, is_window: bool) -> str:
|
|
82
|
+
# resolved is "pkg.name" (your analyzer already normalizes)
|
|
83
|
+
pkg, name = resolved.rsplit(".", 1) if "." in resolved else ("", resolved)
|
|
84
|
+
if is_window:
|
|
85
|
+
return f"input_boolean.hassl_sched_{_safe(pkg)}_{_safe(name)}"
|
|
86
|
+
return f"binary_sensor.hassl_schedule_{_safe(pkg)}_{_safe(name)}_active"
|
|
87
|
+
|
|
81
88
|
def _turn_service(domain: str, state_on: bool) -> str:
|
|
82
89
|
if domain in ("light","switch","fan","media_player","cover"):
|
|
83
90
|
return f"{domain}.turn_on" if state_on else f"{domain}.turn_off"
|
|
@@ -92,7 +99,8 @@ class ScheduleRegistry:
|
|
|
92
99
|
pkg: str
|
|
93
100
|
created: Dict[str, str] = field(default_factory=dict) # name -> entity_id
|
|
94
101
|
sensors: List[Dict] = field(default_factory=list) # collected template sensors (for YAML)
|
|
95
|
-
|
|
102
|
+
period_cache: Dict[Tuple[str,str], str] = field(default_factory=dict) # (sched, key)-> entity_id
|
|
103
|
+
|
|
96
104
|
def eid_for(self, name: str) -> str:
|
|
97
105
|
return f"binary_sensor.hassl_schedule_{self.pkg}_{_safe(name)}_active".lower()
|
|
98
106
|
|
|
@@ -105,6 +113,128 @@ class ScheduleRegistry:
|
|
|
105
113
|
self.created[name] = eid
|
|
106
114
|
return eid
|
|
107
115
|
|
|
116
|
+
# New: create/reuse a period template sensor for a schedule window
|
|
117
|
+
def ensure_period_sensor(self, sched_name: str, period: Dict[str, Any] | None) -> str | None:
|
|
118
|
+
if not period:
|
|
119
|
+
return None
|
|
120
|
+
key = (sched_name, str(period))
|
|
121
|
+
if key in self.period_cache:
|
|
122
|
+
return self.period_cache[key]
|
|
123
|
+
# Build a compact name with hash for stability
|
|
124
|
+
eid_name = f"hassl_period_{self.pkg}_{_safe(sched_name)}_{abs(hash(str(period)))%100000}"
|
|
125
|
+
entity_id = f"binary_sensor.{eid_name}"
|
|
126
|
+
tpl = _period_template(period)
|
|
127
|
+
self.sensors.append({"name": eid_name, "unique_id": eid_name, "state": f"{{{{ {tpl} }}}}"})
|
|
128
|
+
self.period_cache[key] = entity_id
|
|
129
|
+
return entity_id
|
|
130
|
+
|
|
131
|
+
# ---------- NEW: window helpers (mirrors rules_min logic) ----------
|
|
132
|
+
def _parse_offset(off: str) -> str:
|
|
133
|
+
if not off: return "00:00:00"
|
|
134
|
+
m = re.fullmatch(r"([+-])(\d+)(ms|s|m|h|d)", str(off).strip())
|
|
135
|
+
if not m: return "00:00:00"
|
|
136
|
+
sign, n, unit = m.group(1), int(m.group(2)), m.group(3)
|
|
137
|
+
seconds = {"ms": 0, "s": n, "m": n*60, "h": n*3600, "d": n*86400}[unit]
|
|
138
|
+
h = seconds // 3600
|
|
139
|
+
m_ = (seconds % 3600) // 60
|
|
140
|
+
s = seconds % 60
|
|
141
|
+
return f"{sign}{h:02d}:{m_:02d}:{s:02d}"
|
|
142
|
+
|
|
143
|
+
def _wrap_tpl(expr: str) -> str:
|
|
144
|
+
"""Ensure a Jinja expression is wrapped safely in {{ … }}."""
|
|
145
|
+
expr = expr.strip()
|
|
146
|
+
if expr.startswith("{{") and expr.endswith("}}"):
|
|
147
|
+
return expr
|
|
148
|
+
return "{{ " + expr + " }}"
|
|
149
|
+
|
|
150
|
+
def _clock_between_cond(hhmm_start: str, hhmm_end: str):
|
|
151
|
+
# Pure expression (no {% %} / inner {{ }}), safe to embed in {{ ... }}
|
|
152
|
+
ns = "now().strftime('%H:%M')"
|
|
153
|
+
s = hhmm_start
|
|
154
|
+
e = hhmm_end
|
|
155
|
+
|
|
156
|
+
expr = (
|
|
157
|
+
f"( ('{s}' < '{e}' and ({ns} >= '{s}' and {ns} < '{e}')) "
|
|
158
|
+
f"or ('{s}' >= '{e}' and ({ns} >= '{s}' or {ns} < '{e}')) )"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"condition": "template",
|
|
163
|
+
"value_template": _wrap_tpl(expr)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def _sun_edge_cond(edge: str, ts: dict):
|
|
167
|
+
event = ts.get("event", "sunrise")
|
|
168
|
+
off = _parse_offset(ts.get("offset", "0s"))
|
|
169
|
+
cond = {"condition": "sun", edge: event}
|
|
170
|
+
if off and off != "00:00:00":
|
|
171
|
+
cond["offset"] = off
|
|
172
|
+
return cond
|
|
173
|
+
|
|
174
|
+
def _window_condition_from_specs(start_ts, end_ts):
|
|
175
|
+
# clock → clock
|
|
176
|
+
if isinstance(start_ts, dict) and start_ts.get("kind") == "clock" and \
|
|
177
|
+
isinstance(end_ts, dict) and end_ts.get("kind") == "clock":
|
|
178
|
+
s = start_ts.get("value", "00:00")
|
|
179
|
+
e = end_ts.get("value", "00:00")
|
|
180
|
+
return _clock_between_cond(s, e)
|
|
181
|
+
# sun → sun
|
|
182
|
+
if isinstance(start_ts, dict) and start_ts.get("kind") == "sun" and \
|
|
183
|
+
isinstance(end_ts, dict) and end_ts.get("kind") == "sun":
|
|
184
|
+
after_start = _sun_edge_cond("after", start_ts)
|
|
185
|
+
before_end = _sun_edge_cond("before", end_ts)
|
|
186
|
+
wrap = (start_ts.get("event") == "sunset" and end_ts.get("event") == "sunrise")
|
|
187
|
+
if wrap:
|
|
188
|
+
return {"condition": "or", "conditions": [after_start, before_end]}
|
|
189
|
+
return {"condition": "and", "conditions": [after_start, before_end]}
|
|
190
|
+
# mixed → minute-of-day template (pure expression, no {% %})
|
|
191
|
+
NOWM = "(now().hour*60 + now().minute)"
|
|
192
|
+
SM = "(((start.value[0:2]|int)*60 + (start.value[3:5]|int)) if start.kind == 'clock' else (as_local(state_attr('sun.sun','next_' ~ start.event)).hour*60 + as_local(state_attr('sun.sun','next_' ~ start.event)).minute))"
|
|
193
|
+
EM = "(((end.value[0:2]|int)*60 + (end.value[3:5]|int)) if end.kind == 'clock' else (as_local(state_attr('sun.sun','next_' ~ end.event)).hour*60 + as_local(state_attr('sun.sun','next_' ~ end.event)).minute))"
|
|
194
|
+
return {
|
|
195
|
+
"condition": "template",
|
|
196
|
+
"value_template": (
|
|
197
|
+
f"( ({SM} < {EM} and ({NOWM} >= {SM} and {NOWM} < {EM})) "
|
|
198
|
+
f"or ({SM} >= {EM} and ({NOWM} >= {SM} or {NOWM} < {EM})) )"
|
|
199
|
+
),
|
|
200
|
+
"variables": {"start": start_ts or {}, "end": end_ts or {}}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _day_selector_condition(sel: Optional[str]):
|
|
205
|
+
if sel == "weekdays":
|
|
206
|
+
return {"condition": "time", "weekday": ["mon","tue","wed","thu","fri"]}
|
|
207
|
+
if sel == "weekends":
|
|
208
|
+
return {"condition": "time", "weekday": ["sat","sun"]}
|
|
209
|
+
# daily / None
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def _holiday_condition(mode: Optional[str], hol_id: Optional[str]):
|
|
213
|
+
if not (mode and hol_id):
|
|
214
|
+
return None
|
|
215
|
+
# True when today is a holiday for 'only', false when 'except'
|
|
216
|
+
eid = f"binary_sensor.hassl_holiday_{hol_id}"
|
|
217
|
+
return {"condition": "state", "entity_id": eid, "state": "on" if mode == "only" else "off"}
|
|
218
|
+
|
|
219
|
+
def _trigger_for(ts: Dict[str, Any]) -> Dict[str, Any]:
|
|
220
|
+
"""
|
|
221
|
+
Build a HA trigger for a time-spec dict:
|
|
222
|
+
- {"kind":"clock","value":"HH:MM"} -> time at HH:MM:00
|
|
223
|
+
- {"kind":"sun","event":"sunrise|sunset","offset":"+15m"} -> sun trigger (with offset)
|
|
224
|
+
"""
|
|
225
|
+
if isinstance(ts, dict) and ts.get("kind") == "clock":
|
|
226
|
+
hhmm = ts.get("value", "00:00")
|
|
227
|
+
at = hhmm if len(hhmm) == 8 else (hhmm + ":00" if len(hhmm) == 5 else "00:00:00")
|
|
228
|
+
return {"platform": "time", "at": str(at)}
|
|
229
|
+
if isinstance(ts, dict) and ts.get("kind") == "sun":
|
|
230
|
+
trig = {"platform": "sun", "event": ts.get("event", "sunrise")}
|
|
231
|
+
off = _parse_offset(ts.get("offset", "0s"))
|
|
232
|
+
if off and off != "00:00:00":
|
|
233
|
+
trig["offset"] = off
|
|
234
|
+
return trig
|
|
235
|
+
# Fallback: evaluate soon; maintenance automation will correct state anyway
|
|
236
|
+
return {"platform": "time_pattern", "minutes": "/1"}
|
|
237
|
+
|
|
108
238
|
def _jinja_offset(offset: str) -> str:
|
|
109
239
|
"""
|
|
110
240
|
Convert '+15m'/'-10s'/'2h' to a Jinja timedelta expression snippet:
|
|
@@ -236,6 +366,49 @@ def _emit_schedule_helper_yaml(entity_id: str, pkg: str, name: str, clauses: Lis
|
|
|
236
366
|
"state": f"{{{{ {state_tpl} }}}}"
|
|
237
367
|
}
|
|
238
368
|
|
|
369
|
+
# ---------- NEW: period sensor template builders ----------
|
|
370
|
+
def _period_template(period: Dict[str, Any]) -> str:
|
|
371
|
+
"""
|
|
372
|
+
period is a dict of shape:
|
|
373
|
+
{"kind":"months","data":{"list":[Mon,...]}} or {"kind":"months","data":{"range":[A,B]}}
|
|
374
|
+
{"kind":"dates","data":{"start":"MM-DD","end":"MM-DD"}} # can wrap year
|
|
375
|
+
{"kind":"range","data":{"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}}
|
|
376
|
+
Returns a Jinja boolean expression.
|
|
377
|
+
"""
|
|
378
|
+
kind = period.get("kind")
|
|
379
|
+
data = period.get("data", {})
|
|
380
|
+
|
|
381
|
+
if kind == "months":
|
|
382
|
+
def m2n(m: str) -> int:
|
|
383
|
+
order = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
|
|
384
|
+
return order.index(m)+1
|
|
385
|
+
if "list" in data:
|
|
386
|
+
months = [m2n(m) for m in data["list"]]
|
|
387
|
+
return f"( now().month in {months} )"
|
|
388
|
+
if "range" in data:
|
|
389
|
+
a, b = [m2n(x) for x in data["range"]]
|
|
390
|
+
return (
|
|
391
|
+
f"( ({a} <= now().month <= {b}) or "
|
|
392
|
+
f" ({a} > {b} and (now().month >= {a} or now().month <= {b})) )"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if kind == "dates":
|
|
396
|
+
# Compare zero-padded strings '%m-%d' (lexicographic works).
|
|
397
|
+
start = data.get("start"); end = data.get("end")
|
|
398
|
+
d = "now().strftime('%m-%d')"
|
|
399
|
+
return (
|
|
400
|
+
f"( ('{start}' <= {d} <= '{end}') or "
|
|
401
|
+
f" ('{start}' > '{end}' and ({d} >= '{start}' or {d} <= '{end}')) )"
|
|
402
|
+
)
|
|
403
|
+
if kind == "range":
|
|
404
|
+
start = data.get("start"); end = data.get("end")
|
|
405
|
+
# Keep this as a pure expression using filters.
|
|
406
|
+
return (
|
|
407
|
+
f"( now().date() >= '{start}'|as_datetime|date and "
|
|
408
|
+
f" now().date() <= '{end}'|as_datetime|date )"
|
|
409
|
+
)
|
|
410
|
+
return "true"
|
|
411
|
+
|
|
239
412
|
def _collect_named_schedules(ir: IRProgram) -> Iterable[Dict]:
|
|
240
413
|
"""
|
|
241
414
|
Collect named schedules from IR in either object, list, or dict form.
|
|
@@ -295,7 +468,6 @@ def _collect_named_schedules(ir: IRProgram) -> Iterable[Dict]:
|
|
|
295
468
|
def emit_package(ir: IRProgram, outdir: str):
|
|
296
469
|
ensure_dir(outdir)
|
|
297
470
|
|
|
298
|
-
print("DEBUG:", getattr(ir, "schedules", None))
|
|
299
471
|
# derive package slug early; use IR package if present
|
|
300
472
|
pkg = getattr(ir, "package", None) or _pkg_slug(outdir)
|
|
301
473
|
sched_reg = ScheduleRegistry(pkg)
|
|
@@ -304,12 +476,33 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
304
476
|
scripts: Dict = {"script": {}}
|
|
305
477
|
automations: List[Dict] = []
|
|
306
478
|
|
|
479
|
+
# We no longer emit legacy YAML 'platform: workday' sections.
|
|
480
|
+
# Only emit template sensors that reference UI-defined Workday entities.
|
|
481
|
+
holiday_tpl_defs: List[Dict] = []
|
|
482
|
+
|
|
307
483
|
# ---------- PASS 1: create named schedule helpers ONCE per (pkg, name) ----------
|
|
308
484
|
for s in _collect_named_schedules(ir):
|
|
309
485
|
if not s.get("name"):
|
|
310
486
|
continue
|
|
311
487
|
sched_reg.register_decl(s["name"], s.get("clauses", []))
|
|
312
488
|
|
|
489
|
+
# ---------- PASS 1b: Holidays -> (template only; Workday via UI) ----------
|
|
490
|
+
# ir.holidays is {"id": {...}}; we only need the id to reference UI entities.
|
|
491
|
+
holidays_ir = getattr(ir, "holidays", {}) or {}
|
|
492
|
+
if holidays_ir:
|
|
493
|
+
for hid, h in holidays_ir.items():
|
|
494
|
+
# Template: holiday = NOT(not_holiday)
|
|
495
|
+
# Assumes you configured a UI Workday instance that:
|
|
496
|
+
# - has workdays = Mon..Sun
|
|
497
|
+
# - excludes = ['holiday']
|
|
498
|
+
# and renamed it to: binary_sensor.hassl_<id>_not_holiday
|
|
499
|
+
eid_name = f"hassl_holiday_{hid}"
|
|
500
|
+
holiday_tpl_defs.append({
|
|
501
|
+
"name": eid_name,
|
|
502
|
+
"unique_id": eid_name,
|
|
503
|
+
"state": "{{ is_state('binary_sensor.hassl_" + hid + "_not_holiday', 'off') }}"
|
|
504
|
+
})
|
|
505
|
+
|
|
313
506
|
# ---------- Context helpers for entities & per-prop contexts ----------
|
|
314
507
|
sync_entities = set(); entity_props = {}
|
|
315
508
|
for s in ir.syncs:
|
|
@@ -436,11 +629,13 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
436
629
|
)
|
|
437
630
|
})
|
|
438
631
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
632
|
+
ptype = PROP_CONFIG.get(prop, {}).get("proxy", {}).get("type")
|
|
633
|
+
if ptype == "input_text":
|
|
634
|
+
proxy_e = f"input_text.hassl_{_safe(s.name)}_{prop}"
|
|
635
|
+
elif ptype == "input_boolean":
|
|
636
|
+
proxy_e = f"input_boolean.hassl_{_safe(s.name)}_{prop}"
|
|
637
|
+
else:
|
|
638
|
+
proxy_e = f"input_number.hassl_{_safe(s.name)}_{prop}"
|
|
444
639
|
|
|
445
640
|
if prop == "mute":
|
|
446
641
|
actions = [{
|
|
@@ -513,11 +708,14 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
513
708
|
})
|
|
514
709
|
automations.append({"alias": f"HASSL sync {s.name} downstream onoff","mode":"queued","max":10,"trigger": trigger,"action": actions})
|
|
515
710
|
else:
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
711
|
+
ptype = PROP_CONFIG.get(prop, {}).get("proxy", {}).get("type")
|
|
712
|
+
if ptype == "input_text":
|
|
713
|
+
proxy_e = f"input_text.hassl_{_safe(s.name)}_{prop}"
|
|
714
|
+
elif ptype == "input_boolean":
|
|
715
|
+
proxy_e = f"input_boolean.hassl_{_safe(s.name)}_{prop}"
|
|
716
|
+
else:
|
|
717
|
+
proxy_e = f"input_number.hassl_{_safe(s.name)}_{prop}"
|
|
718
|
+
|
|
521
719
|
trigger = [{"platform": "state","entity_id": proxy_e}]
|
|
522
720
|
actions = []
|
|
523
721
|
cfg = PROP_CONFIG.get(prop, {})
|
|
@@ -551,6 +749,134 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
551
749
|
})
|
|
552
750
|
automations.append({"alias": f"HASSL sync {s.name} downstream {prop}","mode":"queued","max":10,"trigger": trigger,"action": actions})
|
|
553
751
|
|
|
752
|
+
# ---------- New schedule windows (emit input_boolean + minute/sun maintenance automation) ----------
|
|
753
|
+
# IR provides schedules_windows: { name: [ {start,end,day_selector,period,holiday_*} ] }
|
|
754
|
+
sched_windows_ir = getattr(ir, "schedules_windows", {}) or {}
|
|
755
|
+
per_schedule_automations: Dict[str, List[Dict]] = {}
|
|
756
|
+
for sched_name, wins in sched_windows_ir.items():
|
|
757
|
+
# Ensure schedule boolean exists in helpers (include pkg prefix!)
|
|
758
|
+
sched_bool_key = f"hassl_sched_{_safe(pkg)}_{_safe(sched_name)}"
|
|
759
|
+
helpers["input_boolean"][sched_bool_key] = {
|
|
760
|
+
"name": f"HASSL Schedule {pkg}.{sched_name}"
|
|
761
|
+
}
|
|
762
|
+
bool_eid = f"input_boolean.{sched_bool_key}"
|
|
763
|
+
|
|
764
|
+
# --- Back-compat: emit '_active' template mirrors that follow the input_boolean ---
|
|
765
|
+
# 1) Primary mirror: binary_sensor.hassl_schedule_<pkg>_<name>_active
|
|
766
|
+
pkg_safe = _safe(pkg)
|
|
767
|
+
mirror_name = f"hassl_schedule_{pkg_safe}_{_safe(sched_name)}_active"
|
|
768
|
+
sched_reg.sensors.append({
|
|
769
|
+
"name": mirror_name,
|
|
770
|
+
"unique_id": mirror_name,
|
|
771
|
+
"state": "{{ is_state('" + bool_eid + "', 'on') }}"
|
|
772
|
+
})
|
|
773
|
+
# 2) Legacy alias (no pkg): binary_sensor.hassl_schedule_automations_<name>_active
|
|
774
|
+
# Some existing rulegen referenced this older name; keep it as a thin mirror.
|
|
775
|
+
legacy_alias = f"hassl_schedule_automations_{_safe(sched_name)}_active"
|
|
776
|
+
sched_reg.sensors.append({
|
|
777
|
+
"name": legacy_alias,
|
|
778
|
+
"unique_id": legacy_alias,
|
|
779
|
+
"state": "{{ is_state('" + bool_eid + "', 'on') }}"
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
# Build OR-of-windows condition bundles
|
|
783
|
+
or_conditions: List[Dict[str, Any]] = []
|
|
784
|
+
need_sun_triggers = False
|
|
785
|
+
|
|
786
|
+
for idx, w in enumerate(wins):
|
|
787
|
+
|
|
788
|
+
ds = w.get("day_selector")
|
|
789
|
+
href = w.get("holiday_ref")
|
|
790
|
+
hmode = w.get("holiday_mode")
|
|
791
|
+
period = w.get("period")
|
|
792
|
+
|
|
793
|
+
# Coerce time specs to dicts compatible with _trigger_for/_window_condition_from_specs
|
|
794
|
+
raw_start = w.get("start")
|
|
795
|
+
raw_end = w.get("end")
|
|
796
|
+
def _coerce(ts):
|
|
797
|
+
if isinstance(ts, dict):
|
|
798
|
+
return ts
|
|
799
|
+
if isinstance(ts, str):
|
|
800
|
+
# accept "HH:MM" or "HH:MM:SS"
|
|
801
|
+
v = ts if len(ts) in (5,8) else "00:00"
|
|
802
|
+
return {"kind":"clock","value": v[:5] if len(v)==5 else v[:8]}
|
|
803
|
+
return {"kind":"clock","value":"00:00"}
|
|
804
|
+
start_ts = _coerce(raw_start)
|
|
805
|
+
end_ts = _coerce(raw_end)
|
|
806
|
+
|
|
807
|
+
# window condition (handles clock↔sun and wrap)
|
|
808
|
+
window_cond = _window_condition_from_specs(start_ts, end_ts)
|
|
809
|
+
if start_ts.get("kind") == "sun" or end_ts.get("kind") == "sun":
|
|
810
|
+
need_sun_triggers = True
|
|
811
|
+
|
|
812
|
+
# day selector & holiday & period
|
|
813
|
+
conds_and = [ c for c in (_day_selector_condition(ds),
|
|
814
|
+
_holiday_condition(hmode, href),
|
|
815
|
+
window_cond)
|
|
816
|
+
if c is not None ]
|
|
817
|
+
period_eid = sched_reg.ensure_period_sensor(sched_name, period)
|
|
818
|
+
if period_eid:
|
|
819
|
+
conds_and.append({"condition":"state", "entity_id": period_eid, "state":"on"})
|
|
820
|
+
or_conditions.append({ "condition": "and", "conditions": conds_and })
|
|
821
|
+
|
|
822
|
+
# --- Per-window explicit ON/OFF automations (edges only) ---
|
|
823
|
+
edge_conds = [ _day_selector_condition(ds), _holiday_condition(hmode, href) ]
|
|
824
|
+
if period_eid:
|
|
825
|
+
edge_conds.append({"condition": "state", "entity_id": period_eid, "state": "on"})
|
|
826
|
+
edge_conds = [c for c in edge_conds if c]
|
|
827
|
+
|
|
828
|
+
on_auto = {
|
|
829
|
+
"alias": f"HASSL schedule {pkg}.{sched_name} on_{idx}",
|
|
830
|
+
"mode": "single",
|
|
831
|
+
"trigger": [ _trigger_for(start_ts) ],
|
|
832
|
+
"action": [ { "service": "input_boolean.turn_on", "target": {"entity_id": bool_eid} } ]
|
|
833
|
+
}
|
|
834
|
+
ec = [c for c in edge_conds if c]
|
|
835
|
+
if ec:
|
|
836
|
+
on_auto["condition"] = ec
|
|
837
|
+
per_schedule_automations.setdefault(sched_name, []).append(on_auto)
|
|
838
|
+
|
|
839
|
+
off_auto = {
|
|
840
|
+
"alias": f"HASSL schedule {pkg}.{sched_name} off_{idx}",
|
|
841
|
+
"mode": "single",
|
|
842
|
+
"trigger": [ _trigger_for(end_ts) ],
|
|
843
|
+
"action": [ { "service": "input_boolean.turn_off", "target": {"entity_id": bool_eid} } ]
|
|
844
|
+
}
|
|
845
|
+
if ec:
|
|
846
|
+
off_auto["condition"] = ec
|
|
847
|
+
per_schedule_automations.setdefault(sched_name, []).append(off_auto)
|
|
848
|
+
|
|
849
|
+
# Composite choose: ON when any window matches, else OFF
|
|
850
|
+
choose_block = [{
|
|
851
|
+
"conditions": [{ "condition": "or", "conditions": or_conditions }] if or_conditions else [{"condition":"template","value_template":"false"}],
|
|
852
|
+
"sequence": [{"service": "input_boolean.turn_on", "target": {"entity_id": bool_eid}}]
|
|
853
|
+
}]
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
triggers = [
|
|
857
|
+
{"platform": "time_pattern", "minutes": "/1"},
|
|
858
|
+
# Re-evaluate on HA restart so the boolean is correct immediately
|
|
859
|
+
{"platform": "homeassistant", "event": "start"},
|
|
860
|
+
]
|
|
861
|
+
if need_sun_triggers:
|
|
862
|
+
# Nudge immedately at edges so the boolean flips promptly
|
|
863
|
+
triggers.extend([
|
|
864
|
+
{"platform": "sun", "event": "sunrise"},
|
|
865
|
+
{"platform": "sun", "event": "sunset"}
|
|
866
|
+
])
|
|
867
|
+
|
|
868
|
+
per_schedule_automations.setdefault(sched_name, []).append({
|
|
869
|
+
"alias": f"HASSL schedule {pkg}.{sched_name} maint",
|
|
870
|
+
"mode": "single",
|
|
871
|
+
"trigger": triggers,
|
|
872
|
+
"condition": [],
|
|
873
|
+
"action": [
|
|
874
|
+
{"choose": choose_block,
|
|
875
|
+
"default": [{"service": "input_boolean.turn_off", "target": {"entity_id": bool_eid}}]
|
|
876
|
+
}
|
|
877
|
+
]
|
|
878
|
+
})
|
|
879
|
+
|
|
554
880
|
# ---------- Write YAML ----------
|
|
555
881
|
# helpers & scripts
|
|
556
882
|
_dump_yaml(os.path.join(outdir, f"helpers_{pkg}.yaml"), helpers, ensure_sections=True)
|
|
@@ -563,8 +889,22 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
563
889
|
{"template": [{"binary_sensor": sched_reg.sensors}]}
|
|
564
890
|
)
|
|
565
891
|
|
|
892
|
+
# Holidays file: emit only the template sensors; Workday instances are created via UI
|
|
893
|
+
if holiday_tpl_defs:
|
|
894
|
+
hol_doc: Dict[str, Any] = {}
|
|
895
|
+
hol_doc["template"] = [{"binary_sensor": holiday_tpl_defs}]
|
|
896
|
+
_dump_yaml(os.path.join(outdir, f"holidays_{pkg}.yaml"), hol_doc)
|
|
897
|
+
|
|
566
898
|
# automations per sync
|
|
567
899
|
for s in ir.syncs:
|
|
568
900
|
doc = [a for a in automations if a["alias"].startswith(f"HASSL sync {s.name}")]
|
|
569
901
|
if doc:
|
|
570
902
|
_dump_yaml(os.path.join(outdir, f"sync_{pkg}_{_safe(s.name)}.yaml"), {"automation": doc})
|
|
903
|
+
|
|
904
|
+
# automations per schedule (new windows)
|
|
905
|
+
for sched_name, autos in per_schedule_automations.items():
|
|
906
|
+
if autos:
|
|
907
|
+
_dump_yaml(
|
|
908
|
+
os.path.join(outdir, f"schedule_{pkg}_{_safe(sched_name)}.yaml"),
|
|
909
|
+
{"automation": autos}
|
|
910
|
+
)
|
hassl/codegen/rules_min.py
CHANGED
|
@@ -404,14 +404,52 @@ def generate_rules(ir, outdir):
|
|
|
404
404
|
rname = rule["name"]
|
|
405
405
|
gate = _gate_entity(rname)
|
|
406
406
|
|
|
407
|
-
#
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
407
|
+
# Build per-schedule gate conditions.
|
|
408
|
+
# For each referenced schedule, OR together its possible gate entities
|
|
409
|
+
# (e.g., input_boolean.hassl_sched_* OR binary_sensor.hassl_schedule_*_active).
|
|
410
|
+
schedule_gate_conditions = []
|
|
411
|
+
|
|
412
|
+
rule_gates = list(rule.get("schedule_gates") or []) if isinstance(rule, dict) else []
|
|
413
|
+
used_names = list(use_by_rule.get(rname, []) or [])
|
|
414
|
+
|
|
415
|
+
if rule_gates:
|
|
416
|
+
for g in rule_gates:
|
|
417
|
+
ents = [e for e in (g.get("entities") or []) if isinstance(e, str)]
|
|
418
|
+
# Also include the legacy, current-outdir slug binary_sensor expected by older tests/code
|
|
419
|
+
# Determine the base schedule name, then synthesize the local sensor id.
|
|
420
|
+
resolved = str(g.get("resolved", "")) if isinstance(g.get("resolved", ""), str) else ""
|
|
421
|
+
base = resolved.rsplit(".", 1)[-1] if resolved else None
|
|
422
|
+
if base:
|
|
423
|
+
legacy_local = _schedule_sensor(base, pkg) # e.g., binary_sensor.hassl_schedule_out_std_<base>_active
|
|
424
|
+
if legacy_local not in ents:
|
|
425
|
+
ents.append(legacy_local)
|
|
426
|
+
|
|
427
|
+
if not ents:
|
|
428
|
+
continue
|
|
429
|
+
if len(ents) == 1:
|
|
430
|
+
schedule_gate_conditions.append({
|
|
431
|
+
"condition": "state",
|
|
432
|
+
"entity_id": ents[0],
|
|
433
|
+
"state": "on"
|
|
434
|
+
})
|
|
435
|
+
else:
|
|
436
|
+
schedule_gate_conditions.append({
|
|
437
|
+
"condition": "or",
|
|
438
|
+
"conditions": [
|
|
439
|
+
{"condition": "state", "entity_id": e, "state": "on"}
|
|
440
|
+
for e in ents
|
|
441
|
+
]
|
|
442
|
+
})
|
|
443
|
+
else:
|
|
444
|
+
# Legacy fallback: only the template binary_sensor is known.
|
|
445
|
+
for nm in used_names:
|
|
446
|
+
base = str(nm).split(".")[-1]
|
|
447
|
+
decl_pkg = exported_sched_pkgs.get(base, pkg)
|
|
448
|
+
schedule_gate_conditions.append({
|
|
449
|
+
"condition": "state",
|
|
450
|
+
"entity_id": _schedule_sensor(base, decl_pkg),
|
|
451
|
+
"state": "on"
|
|
452
|
+
})
|
|
415
453
|
|
|
416
454
|
# 2) inline schedule clauses → compile directly to HA conditions (no helpers)
|
|
417
455
|
inline_clauses = inline_by_rule.get(rname, []) or []
|
|
@@ -420,9 +458,6 @@ def generate_rules(ir, outdir):
|
|
|
420
458
|
if isinstance(cl, dict) and cl.get("type") == "schedule_clause":
|
|
421
459
|
inline_schedule_conditions.append(_schedule_clause_to_condition(cl))
|
|
422
460
|
|
|
423
|
-
# de-dup schedule conditions
|
|
424
|
-
cond_schedule_entities = sorted(set(cond_schedule_entities))
|
|
425
|
-
|
|
426
461
|
# Now process each 'if' clause
|
|
427
462
|
for idx, clause in enumerate(rule["clauses"]):
|
|
428
463
|
# Each clause is {"condition": ..., "actions": [...]}
|
|
@@ -440,8 +475,10 @@ def generate_rules(ir, outdir):
|
|
|
440
475
|
cond_ha = _condition_to_ha(cond_in)
|
|
441
476
|
gate_cond = {"condition": "state", "entity_id": gate, "state": "on"}
|
|
442
477
|
|
|
443
|
-
# schedule gate conditions (all must be satisfied)
|
|
444
|
-
|
|
478
|
+
# schedule gate conditions (all must be satisfied);
|
|
479
|
+
# each item in schedule_gate_conditions is already either a state check
|
|
480
|
+
# or an OR of multiple state checks for a single schedule.
|
|
481
|
+
sched_conds = list(schedule_gate_conditions)
|
|
445
482
|
if inline_schedule_conditions:
|
|
446
483
|
sched_conds.extend(inline_schedule_conditions)
|
|
447
484
|
|
hassl/parser/hassl.lark
CHANGED
|
@@ -9,7 +9,8 @@ stmt: package_decl
|
|
|
9
9
|
| alias
|
|
10
10
|
| sync
|
|
11
11
|
| rule
|
|
12
|
-
| schedule_decl
|
|
12
|
+
| schedule_decl
|
|
13
|
+
| holidays_decl
|
|
13
14
|
|
|
14
15
|
// --- Aliases ---
|
|
15
16
|
alias: PRIVATE? "alias" CNAME "=" entity //can mark private
|
|
@@ -105,7 +106,35 @@ OFFSET: /[+-]\d+(ms|s|m|h|d)/
|
|
|
105
106
|
schedule_decl: PRIVATE? SCHEDULE CNAME ":" schedule_clause+
|
|
106
107
|
|
|
107
108
|
// Clauses usable both in declarations and inline
|
|
108
|
-
|
|
109
|
+
// Keep legacy form AND add new 'on …' forms
|
|
110
|
+
schedule_clause: schedule_legacy_clause
|
|
111
|
+
| schedule_new_clause
|
|
112
|
+
|
|
113
|
+
// Legacy (unchanged)
|
|
114
|
+
schedule_legacy_clause: schedule_op FROM time_spec schedule_end? ";"
|
|
115
|
+
|
|
116
|
+
// NEW schedule window syntax:
|
|
117
|
+
// [during <period>] on (weekdays|weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
|
|
118
|
+
// on holidays <id> HH:MM-HH:MM ;
|
|
119
|
+
schedule_new_clause: period? "on" day_selector time_range holiday_mod? ";"
|
|
120
|
+
| "on" "holidays" CNAME time_range ";"
|
|
121
|
+
|
|
122
|
+
// Periods
|
|
123
|
+
period: "during" "months" month_range
|
|
124
|
+
| "during" "dates" mmdd_range
|
|
125
|
+
| "during" "range" ymd_range
|
|
126
|
+
|
|
127
|
+
month_range: MONTH (".." MONTH)? ("," MONTH)*
|
|
128
|
+
MONTH.2: "Jan"|"Feb"|"Mar"|"Apr"|"May"|"Jun"|"Jul"|"Aug"|"Sep"|"Oct"|"Nov"|"Dec"
|
|
129
|
+
mmdd_range: MMDD ".." MMDD
|
|
130
|
+
MMDD: /\d{2}-\d{2}/
|
|
131
|
+
ymd_range: YMD ".." YMD
|
|
132
|
+
YMD: /\d{4}-\d{2}-\d{2}/
|
|
133
|
+
|
|
134
|
+
day_selector: "weekdays" | "weekends" | "daily"
|
|
135
|
+
time_range: TIME_HHMM "-" TIME_HHMM
|
|
136
|
+
holiday_mod: "except" "holidays" CNAME
|
|
137
|
+
|
|
109
138
|
schedule_op: ENABLE | DISABLE
|
|
110
139
|
schedule_end: TO time_spec -> schedule_to
|
|
111
140
|
| UNTIL time_spec -> schedule_until
|
|
@@ -158,3 +187,20 @@ import_tail: ".*"
|
|
|
158
187
|
| "as" CNAME
|
|
159
188
|
import_list: import_item ("," import_item)*
|
|
160
189
|
import_item: CNAME ("as" CNAME)?
|
|
190
|
+
|
|
191
|
+
// ---- Holidays declaration (NEW) ----
|
|
192
|
+
// Example:
|
|
193
|
+
// holidays us_ca:
|
|
194
|
+
// country="US", province="CA", add=["2025-11-28"], remove=["2025-12-26"]
|
|
195
|
+
holidays_decl: "holidays" CNAME ":" holi_kv ("," holi_kv)*
|
|
196
|
+
holi_kv: "country" "=" STRING
|
|
197
|
+
| "province" "=" STRING
|
|
198
|
+
| "workdays" "=" "[" daylist "]" -> holi_workdays
|
|
199
|
+
| "excludes" "=" "[" excludelist "]" -> holi_excludes
|
|
200
|
+
| "add" "=" "[" datestr_list "]"
|
|
201
|
+
| "remove" "=" "[" datestr_list "]"
|
|
202
|
+
daylist: DAY ("," DAY)*
|
|
203
|
+
excludelist: ("sat"|"sun"|"holiday") ("," ("sat"|"sun"|"holiday"))*
|
|
204
|
+
DAY.2: "mon"|"tue"|"wed"|"thu"|"fri"|"sat"|"sun"
|
|
205
|
+
DATESTR: /"\d{4}-\d{2}-\d{2}"/
|
|
206
|
+
datestr_list: DATESTR ("," DATESTR)*
|