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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.0"
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, e.g. {"type":"schedule_clause", ...}
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
- proxy_e = (
440
- f"input_text.hassl_{_safe(s.name)}_{prop}"
441
- if PROP_CONFIG.get(prop,{}).get("proxy",{}).get("type") == "input_text"
442
- else f"input_number.hassl_{_safe(s.name)}_{prop}"
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
- proxy_e = (
517
- f"input_text.hassl_{_safe(s.name)}_{prop}"
518
- if PROP_CONFIG.get(prop,{}).get("proxy",{}).get("type") == "input_text"
519
- else f"input_number.hassl_{_safe(s.name)}_{prop}"
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
+ )
@@ -404,14 +404,52 @@ def generate_rules(ir, outdir):
404
404
  rname = rule["name"]
405
405
  gate = _gate_entity(rname)
406
406
 
407
- # gather schedule conditions for this rule
408
- cond_schedule_entities = []
409
-
410
- # 1) named schedules used by this rule → state('on') of the schedule sensor
411
- for nm in use_by_rule.get(rname, []) or []:
412
- base = str(nm).split(".")[-1]
413
- decl_pkg = exported_sched_pkgs.get(base, pkg)
414
- cond_schedule_entities.append(_schedule_sensor(base, decl_pkg))
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
- sched_conds = [{"condition":"state","entity_id": e,"state":"on"} for e in cond_schedule_entities]
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
- schedule_clause: schedule_op FROM time_spec schedule_end? ";"
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)*