hassl 0.2.1__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.
@@ -1,6 +1,9 @@
1
- from dataclasses import dataclass
2
- from typing import Dict, List, Any, Optional
3
- from ..ast.nodes import Program, Alias, Sync, Rule
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, List, Any, Optional, Tuple
3
+ from ..ast.nodes import (
4
+ Program, Alias, Sync, Rule, Schedule,
5
+ HolidaySet, ScheduleWindow, PeriodSelector,
6
+ )
4
7
  from .domains import DOMAIN_PROPS, domain_of
5
8
 
6
9
  @dataclass
@@ -21,13 +24,21 @@ class IRRule:
21
24
  clauses: List[dict]
22
25
  schedule_uses: Optional[List[str]] = None
23
26
  schedules_inline: Optional[List[dict]] = None
24
-
27
+ # NEW: for each schedule use, the emitter can gate on ANY of these entity ids.
28
+ # List of {"resolved": "pkg.name", "entities": [entity_id, ...]}
29
+ schedule_gates: Optional[List[Dict[str, Any]]] = None
30
+
25
31
  @dataclass
26
32
  class IRProgram:
27
33
  aliases: Dict[str, str]
28
34
  syncs: List[IRSync]
29
35
  rules: List[IRRule]
36
+ # Legacy schedule clauses (enable/disable from …)
30
37
  schedules: Optional[Dict[str, List[dict]]] = None
38
+ # NEW: declared holiday sets (by id)
39
+ holidays: Optional[Dict[str, dict]] = None
40
+ # NEW: structured windows keyed by schedule name
41
+ schedules_windows: Optional[Dict[str, List[dict]]] = None # NEW
31
42
 
32
43
  def to_dict(self):
33
44
  return {
@@ -40,9 +51,13 @@ class IRProgram:
40
51
  "name": r.name,
41
52
  "clauses": r.clauses,
42
53
  "schedule_uses": r.schedule_uses or [],
43
- "schedules_inline": r.schedules_inline or []
54
+ "schedules_inline": r.schedules_inline or [],
55
+ # surface gates so codegen can choose correct binary_sensor/input_boolean
56
+ "schedule_gates": r.schedule_gates or [],
44
57
  } for r in self.rules],
45
58
  "schedules": self.schedules or {},
59
+ "holidays": self.holidays or {},
60
+ "schedules_windows": self.schedules_windows or {}, # NEW
46
61
  }
47
62
 
48
63
  def _resolve_alias(e: str, amap: Dict[str,str]) -> str:
@@ -78,11 +93,134 @@ def _props_for_sync(kind: str, members: List[str]) -> List[IRSyncedProp]:
78
93
  return []
79
94
 
80
95
  def analyze(prog: Program) -> IRProgram:
81
- # --- Aliases ---
82
- amap: Dict[str,str] = {}
96
+ """
97
+ Import + package semantics
98
+ -------------------------
99
+ - Supports:
100
+ import pkg.* # glob injects public aliases & schedules
101
+ import pkg: a, b as c # list import (with optional renames)
102
+ import pkg as ns # qualified access via 'ns.x'
103
+ - Resolution uses a global export registry if provided by the build:
104
+ GLOBAL_EXPORTS: Dict[(pkg, kind, name), node]
105
+ where kind ∈ {"alias","schedule"} and node is Alias|Schedule.
106
+ Falls back to intra-file visibility if GLOBAL_EXPORTS absent.
107
+ """
108
+ package_name: str = prog.package or ""
109
+
110
+ # Local (this file) exports — public only (private stays local)
111
+ local_aliases: Dict[str, str] = {}
112
+ local_schedules: Dict[str, List[dict]] = {}
113
+ local_public: Dict[Tuple[str, str, str], Any] = {} # (pkg, kind, name) -> node
114
+ local_schedule_windows: Dict[str, List[ScheduleWindow]] = {}
115
+ local_holidays: Dict[str, HolidaySet] = {}
116
+
117
+ # 1) Collect local declarations (aliases & schedules)
83
118
  for s in prog.statements:
84
119
  if isinstance(s, Alias):
85
- amap[s.name] = s.entity
120
+ local_aliases[s.name] = s.entity
121
+ is_private = getattr(s, "private", False)
122
+ if not is_private:
123
+ local_public[(package_name, "alias", s.name)] = s
124
+ elif isinstance(s, dict) and s.get("type") == "schedule_decl":
125
+ name = s.get("name")
126
+ if isinstance(name, str) and name.strip():
127
+ local_schedules.setdefault(name, []).extend(s.get("clauses", []) or [])
128
+ if not s.get("private", False):
129
+ # wrap in a lightweight Schedule node shape for export table parity
130
+ local_public[(package_name, "schedule", name)] = Schedule(name=name, clauses=s.get("clauses", []), private=False)
131
+ elif isinstance(s, Schedule):
132
+ # Only treat as "legacy" if there are actual legacy clauses.
133
+ if getattr(s, "clauses", None):
134
+ local_schedules.setdefault(s.name, []).extend(s.clauses or [])
135
+ # Always collect windows if present.
136
+ if getattr(s, "windows", None):
137
+ local_schedule_windows.setdefault(s.name, []).extend(s.windows or [])
138
+ # Export the schedule either way (legacy or windows) if public.
139
+ if not getattr(s, "private", False):
140
+ local_public[(package_name, "schedule", s.name)] = s
141
+ elif isinstance(s, HolidaySet):
142
+ local_holidays[s.id] = s
143
+
144
+ # 2) Build import view
145
+ # Maps for analysis:
146
+ injected_aliases: Dict[str, str] = {} # local name -> entity id (from imports)
147
+ imported_schedules: Dict[str, Tuple[str, str]] = {} # local name -> (pkg, name)
148
+ qualified_prefixes: Dict[str, str] = {} # ns -> module path (for "import X as ns")
149
+
150
+ # Helper to resolve from global exports if available; otherwise, from locals only
151
+ def _get_export(mod: str, kind: str, name: str) -> Optional[Any]:
152
+ key = (mod, kind, name)
153
+ if 'GLOBAL_EXPORTS' in globals():
154
+ return globals()['GLOBAL_EXPORTS'].get(key)
155
+ # intra-file fallback: only resolve if the target module == this file's package
156
+ if mod == package_name:
157
+ return local_public.get(key)
158
+ return None
159
+
160
+ # Warn or raise if imported modules aren't in GLOBAL_EXPORTS
161
+ def _check_import_exists(mod: str):
162
+ """Emit a warning if module not found in GLOBAL_EXPORTS (user likely compiled only a subdir)."""
163
+ if 'GLOBAL_EXPORTS' not in globals():
164
+ return # single-file compile, skip
165
+ exists = any(pkg == mod for (pkg, _kind, _name) in globals()['GLOBAL_EXPORTS'])
166
+ if not exists:
167
+ import sys
168
+ print(f"[hasslc] WARNING: imported module '{mod}' not found in build inputs "
169
+ f"(run hasslc from a directory that includes it)", file=sys.stderr)
170
+
171
+ # Dig through Program.imports if present (transformer supplies a normalized list)
172
+ for imp in getattr(prog, "imports", []) or []:
173
+ if not isinstance(imp, dict) or imp.get("type") != "import":
174
+ # transformer may also append sentinels to statements; ignore here
175
+ continue
176
+ mod = imp.get("module", "")
177
+ kind = imp.get("kind")
178
+ # Be generous: if transformer emitted "none" or omitted kind, infer it.
179
+ if kind not in ("glob", "list", "alias"):
180
+ if imp.get("items"):
181
+ kind = "list"
182
+ elif imp.get("as"):
183
+ kind = "alias"
184
+ else:
185
+ kind = "glob"
186
+
187
+ _check_import_exists(mod)
188
+ if kind == "glob":
189
+ # bring in all public aliases & schedules from 'mod'
190
+ source = globals().get('GLOBAL_EXPORTS', local_public)
191
+ for (pkg, k, nm), node in source.items():
192
+ if pkg != mod or k not in ("alias", "schedule"):
193
+ continue
194
+ if k == "alias" and isinstance(node, Alias):
195
+ injected_aliases[nm] = node.entity
196
+ elif k == "schedule":
197
+ imported_schedules[nm] = (pkg, nm)
198
+ elif kind == "list":
199
+ for it in imp.get("items") or []:
200
+ nm = it.get("name")
201
+ as_nm = it.get("as") or nm
202
+ # prefer alias, then schedule
203
+ node = _get_export(mod, "alias", nm)
204
+ if isinstance(node, Alias):
205
+ injected_aliases[as_nm] = node.entity
206
+ continue
207
+ node = _get_export(mod, "schedule", nm)
208
+ if isinstance(node, Schedule) or (isinstance(node, dict) and node.get("type") == "schedule_decl"):
209
+ imported_schedules[as_nm] = (mod, nm)
210
+ continue
211
+ raise KeyError(f"ImportError: module '{mod}' has no public symbol '{nm}'")
212
+ elif kind == "alias":
213
+ # import mod as ns -> track mapping for qualified access
214
+ ns = imp.get("as")
215
+ if not ns:
216
+ raise KeyError(f"ImportError: missing alias name for module '{mod}'")
217
+ qualified_prefixes[str(ns)] = mod
218
+ else:
219
+ # ignore unknown import kinds gracefully
220
+ pass
221
+
222
+ # 3) Merge alias maps: imported first, then local (locals win)
223
+ amap: Dict[str, str] = {**injected_aliases, **local_aliases}
86
224
 
87
225
  # --- Syncs ---
88
226
  syncs: List[IRSync] = []
@@ -93,34 +231,141 @@ def analyze(prog: Program) -> IRProgram:
93
231
  props = _props_for_sync(s.kind, mem)
94
232
  syncs.append(IRSync(s.name, s.kind, mem, inv, props))
95
233
 
96
- # --- Top-level schedules (from transformer) ---
234
+ # --- Top-level schedules (from transformer or Schedule nodes) ---
97
235
  scheds: Dict[str, List[dict]] = {}
98
- for st in prog.statements:
99
- # transformer emits: {"type":"schedule_decl","name":..., "clauses":[...]}
100
- if isinstance(st, dict) and st.get("type") == "schedule_decl":
101
- name = st.get("name")
102
- clauses = st.get("clauses", []) or []
103
- if isinstance(name, str) and name.strip():
104
- # no aliasing inside time specs
105
- scheds.setdefault(name, []).extend(clauses)
236
+ # seed with locals collected earlier (so we keep a single source of truth)
237
+ for nm, cls in local_schedules.items():
238
+ scheds.setdefault(nm, []).extend(cls)
239
+
240
+ # NEW: collect structured windows (serialize to plain dicts)
241
+ sched_windows: Dict[str, List[dict]] = {}
242
+ for nm, wins in local_schedule_windows.items():
243
+ out: List[dict] = []
244
+ for w in wins:
245
+ if not isinstance(w, ScheduleWindow):
246
+ continue
247
+ # flatten PeriodSelector to dict for IR portability
248
+ period = None
249
+ if getattr(w, "period", None):
250
+ p = w.period # PeriodSelector
251
+ period = {"kind": p.kind, "data": dict(p.data)}
252
+ out.append({
253
+ "start": w.start,
254
+ "end": w.end,
255
+ "day_selector": w.day_selector,
256
+ "period": period,
257
+ "holiday_ref": w.holiday_ref,
258
+ "holiday_mode": w.holiday_mode,
259
+ })
260
+ if out:
261
+ sched_windows[nm] = out
106
262
 
107
263
  # --- Rules (with schedule use/inline) ---
108
264
  rules: List[IRRule] = []
265
+
266
+ def _safe(s: str) -> str:
267
+ return (s or "").replace(".", "_")
268
+
269
+ def _gate_entities_for(resolved: str) -> List[str]:
270
+ """
271
+ Return both possible entity ids for a resolved schedule name 'pkg.name'.
272
+ - Legacy (template binary_sensor): binary_sensor.hassl_schedule_<pkg>_<name>_active
273
+ - New windows (input_boolean): input_boolean.hassl_sched_<pkg>_<name>
274
+ We include BOTH so downstream rule emitters can OR them safely.
275
+ """
276
+ if "." in resolved:
277
+ pkg, nm = resolved.rsplit(".", 1)
278
+ else:
279
+ pkg, nm = (prog.package or ""), resolved
280
+ legacy = f"binary_sensor.hassl_schedule_{_safe(pkg)}_{_safe(nm)}_active".lower()
281
+ window = f"input_boolean.hassl_sched_{_safe(pkg)}_{_safe(nm)}".lower()
282
+ return [window, legacy]
283
+
284
+ def _resolve_qualified_alias(name: str) -> Optional[str]:
285
+ """
286
+ Resolve a dotted alias reference like 'ns.light_alias' via 'import pkg as ns'.
287
+ Returns the entity string if found, else None.
288
+ """
289
+ if "." not in name:
290
+ return None
291
+ head, tail = name.split(".", 1)
292
+ mod = qualified_prefixes.get(head)
293
+ if not mod:
294
+ return None
295
+ node = _get_export(mod, "alias", tail)
296
+ if isinstance(node, Alias):
297
+ return node.entity
298
+ return None
299
+
300
+ def _resolve_schedule_name(nm: str) -> str:
301
+ """
302
+ Normalize a schedule identifier to a friendly resolved string:
303
+ - local name: keep as-is
304
+ - imported list/glob: keep local alias (as imported), but if we know the
305
+ source package, annotate as 'pkg.name' for consistency
306
+ - qualified 'ns.x': resolve 'ns' to module and return 'module.x' if found
307
+ """
308
+ # local schedule? (either legacy clauses OR window-only)
309
+ if nm in local_schedules or nm in local_schedule_windows:
310
+ return f"{package_name+'.' if package_name else ''}{nm}"
311
+ # imported by name (list or glob)
312
+ if nm in imported_schedules:
313
+ pkg, base = imported_schedules[nm]
314
+ return f"{pkg}.{base}"
315
+ # qualified via ns
316
+ if "." in nm:
317
+ head, tail = nm.split(".", 1)
318
+ mod = qualified_prefixes.get(head)
319
+ if mod:
320
+ node = _get_export(mod, "schedule", tail)
321
+ if node is not None:
322
+ return f"{mod}.{tail}"
323
+ # unknown — leave as-is (analyzer will not fail here; emitter/runner can)
324
+ return nm
325
+
326
+ # Upgrade alias resolution to support qualified 'ns.aliasName' in expr/actions
327
+ def _walk_alias_with_qualified(obj: Any) -> Any:
328
+ if isinstance(obj, dict):
329
+ return {k: _walk_alias_with_qualified(v) for k, v in obj.items()}
330
+ if isinstance(obj, list):
331
+ return [_walk_alias_with_qualified(x) for x in obj]
332
+ if isinstance(obj, str):
333
+ # first try local/unqualified map
334
+ if "." not in obj and obj in amap:
335
+ return amap[obj]
336
+ # then try qualified import alias
337
+ ent = _resolve_qualified_alias(obj)
338
+ if ent:
339
+ return ent
340
+ return obj
341
+
109
342
  for s in prog.statements:
110
343
  if isinstance(s, Rule):
111
344
  clauses: List[dict] = []
112
345
  schedule_uses: List[str] = []
113
346
  schedules_inline: List[dict] = []
347
+ # Per-rule collection of precomputed gate entities
348
+ schedule_gates: List[Dict[str, Any]] = []
114
349
 
115
350
  for c in s.clauses:
116
351
  # IfClause-like items have .condition/.actions
117
352
  if hasattr(c, "condition") and hasattr(c, "actions"):
118
- cond = _walk_alias(c.condition, amap)
119
- acts = _walk_alias(c.actions, amap)
353
+ # Keep alias identifiers intact for tests & codegen (resolve later)
354
+ cond = c.condition
355
+ acts = c.actions
120
356
  clauses.append({"condition": cond, "actions": acts})
121
357
  elif isinstance(c, dict) and c.get("type") == "schedule_use":
122
358
  # {"type":"schedule_use","names":[...]}
123
- schedule_uses.extend([str(n) for n in (c.get("names") or []) if isinstance(n, str)])
359
+ raw = [str(n) for n in (c.get("names") or []) if isinstance(n, str)]
360
+
361
+ # The IR should keep base names (tests assert on this)
362
+ schedule_uses.extend(raw)
363
+ # But also compute resolved names for gate entities (pkg.name when known)
364
+ resolved = [_resolve_schedule_name(n) for n in raw]
365
+ # precompute gates for emitters (binary_sensor + input_boolean forms)
366
+ for rname in resolved:
367
+ schedule_gates.append({"resolved": rname, "entities": _gate_entities_for(rname)})
368
+
124
369
  elif isinstance(c, dict) and c.get("type") == "schedule_inline":
125
370
  # {"type":"schedule_inline","clauses":[...]}
126
371
  for sc in c.get("clauses") or []:
@@ -134,12 +379,43 @@ def analyze(prog: Program) -> IRProgram:
134
379
  name=s.name,
135
380
  clauses=clauses,
136
381
  schedule_uses=schedule_uses,
137
- schedules_inline=schedules_inline
382
+ schedules_inline=schedules_inline,
383
+ schedule_gates=schedule_gates
138
384
  ))
139
385
 
386
+ # -------- NEW: validate schedule windows --------
387
+ allowed_days = {"weekdays", "weekends", "daily"}
388
+ for sched_name, wins in sched_windows.items():
389
+ for w in wins:
390
+ ds = w.get("day_selector")
391
+ if ds not in allowed_days:
392
+ raise ValueError(f"schedule '{sched_name}': invalid day selector '{ds}'")
393
+ href = w.get("holiday_ref")
394
+ hmode = w.get("holiday_mode")
395
+ if href:
396
+ if hmode not in ("except", "only"):
397
+ raise ValueError(f"schedule '{sched_name}': holiday ref '{href}' requires 'except' or 'only'")
398
+ if href not in local_holidays:
399
+ raise ValueError(f"schedule '{sched_name}': unknown holidays '{href}'")
400
+
401
+ # materialize holidays into plain dicts for IR
402
+ holidays_ir: Dict[str, dict] = {}
403
+ for hid, h in local_holidays.items():
404
+ holidays_ir[hid] = {
405
+ "id": h.id,
406
+ "country": h.country,
407
+ "province": h.province,
408
+ "add": list(h.add),
409
+ "remove": list(h.remove),
410
+ "workdays": list(h.workdays),
411
+ "excludes": list(h.excludes),
412
+ }
413
+
140
414
  return IRProgram(
141
415
  aliases=amap,
142
416
  syncs=syncs,
143
417
  rules=rules,
144
- schedules=scheds
418
+ schedules=scheds,
419
+ schedules_windows=sched_windows,
420
+ holidays=holidays_ir
145
421
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hassl
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: HASSL: Home Assistant Simple Scripting Language
5
5
  Home-page: https://github.com/adanowitz/hassl
6
6
  Author: adanowitz
@@ -17,6 +17,8 @@ Dynamic: license-file
17
17
 
18
18
  > **Home Assistant Simple Scripting Language**
19
19
 
20
+ ![Version](https://img.shields.io/badge/version-v0.3.1-blue)
21
+
20
22
  HASSL is a human-friendly domain-specific language (DSL) for building **loop-safe**, **deterministic**, and **composable** automations for [Home Assistant](https://www.home-assistant.io/).
21
23
 
22
24
  It compiles lightweight `.hassl` scripts into fully functional YAML packages that plug directly into Home Assistant, replacing complex automations with a clean, readable syntax.
@@ -28,16 +30,20 @@ It compiles lightweight `.hassl` scripts into fully functional YAML packages tha
28
30
  - **Readable DSL** → write logic like natural language (`if motion && lux < 50 then light = on`)
29
31
  - **Sync devices** → keep switches, dimmers, and fans perfectly in sync
30
32
  - **Schedules** → declare time-based gates (`enable from 08:00 until 19:00`)
33
+ - **Weekday/weekend/holiday schedules** → full support for Home Assistant’s **Workday integration** (v0.3.1)
31
34
  - **Loop-safe** → context ID tracking prevents feedback loops
32
35
  - **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
33
36
  - **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
34
37
  - **Color temperature in Kelvin** → `light.kelvin = 2700`
38
+ - **Modular packages/imports** → split automations across files with public/private exports
35
39
  - **Auto-reload resilience** → schedules re-evaluate automatically on HA restart
36
40
 
37
41
  ---
38
42
 
39
43
  ## 🧰 Example
40
44
 
45
+ ### Basic standalone script
46
+
41
47
  ```hassl
42
48
  alias light = light.wesley_lamp
43
49
  alias motion = binary_sensor.wesley_motion_motion
@@ -64,6 +70,45 @@ Produces a complete Home Assistant package with:
64
70
  - Sync automations for linked devices
65
71
  - Rule-based automations with schedules and `not_by` guards
66
72
 
73
+ ### Using imports across packages
74
+
75
+ ```hassl
76
+ # packages/std/shared.hassl
77
+ package std.shared
78
+
79
+ alias light = light.wesley_lamp
80
+ alias motion = binary_sensor.wesley_motion_motion
81
+ alias lux = sensor.wesley_motion_illuminance
82
+
83
+ schedule wake_hours:
84
+ enable from 08:00 until 19:00;
85
+ ```
86
+
87
+ ```hassl
88
+ # packages/home/landing.hassl
89
+ package home.landing
90
+ import std.shared.*
91
+
92
+ rule wesley_motion_light:
93
+ schedule use wake_hours;
94
+ if (motion && lux < 50)
95
+ then light = on;
96
+ wait (!motion for 10m) light = off
97
+
98
+ rule landing_manual_off:
99
+ if (light == off) not_by any_hassl
100
+ then disable rule wesley_motion_light for 3m
101
+ ```
102
+
103
+ This setup produces:
104
+ - One **shared package** defining reusable aliases and schedules
105
+ - A **landing package** importing and reusing those exports
106
+
107
+ Together, they generate:
108
+ - ✅ Shared schedule sensor (`binary_sensor.hassl_schedule_std_shared_wake_hours_active`)
109
+ - ✅ Cross-package rule automations gated by that schedule
110
+ - ✅ Context-safe helpers and syncs for both packages
111
+
67
112
  ---
68
113
 
69
114
  ## 🏗 Installation
@@ -103,6 +148,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
103
148
  | `scripts_<pkg>.yaml` | Writer scripts with context stamping |
104
149
  | `sync_<pkg>_*.yaml` | Sync automations for each property |
105
150
  | `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
151
+ | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.3.1) |
106
152
 
107
153
  ---
108
154
 
@@ -120,7 +166,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
120
166
 
121
167
  ## 🔒 Loop Safety & Context Tracking
122
168
 
123
- HASSL automatically writes the **parent context ID** into helper entities before performing actions.\
169
+ HASSL automatically writes the **parent context ID** into helper entities before performing actions.
124
170
  This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly, preventing infinite feedback.
125
171
 
126
172
  ---
@@ -129,23 +175,91 @@ This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly
129
175
 
130
176
  All schedules are restart-safe:
131
177
 
132
- - `input_boolean.hassl_schedule_<name>` automatically re-evaluates on startup.
133
- - Triggers are set for both start and end times.
178
+ - `binary_sensor.hassl_schedule_<package>_<name>_active` automatically re-evaluates on startup.
179
+ - Clock and sun-based windows update continuously through HA’s template engine.
134
180
  - Missed events (like mid-day restarts) are recovered automatically.
135
181
 
136
182
  ---
137
183
 
138
- ## 📚 Documentation
184
+ ## 🗓️ Holiday & Workday Integration (v0.3.1)
185
+
186
+ HASSL now supports `holidays <id>:` schedules tied to Home Assistant’s **Workday** integration.
187
+
188
+ To enable holiday and weekday/weekend-aware schedules:
189
+
190
+ ### 1️⃣ Create two Workday sensors in Home Assistant
191
+
192
+ You must create **two Workday integrations** through the Home Assistant UI.
193
+
194
+ #### Sensor 1 — `binary_sensor.hassl_<id>_workday`
195
+ - **Workdays:** Mon–Fri
196
+ - **Excludes:** `holiday`
197
+ - **Meaning:** ON only on real workdays (Mon–Fri that are not holidays).
198
+
199
+ #### Sensor 2 — `binary_sensor.hassl_<id>_not_holiday`
200
+ - **Workdays:** Mon–Sun
201
+ - **Excludes:** `holiday`
202
+ - **Meaning:** ON every day except official holidays (including weekends).
203
+
204
+ > In both, set your **Country** and optional **Province/Region** as needed for your locale (e.g., `US`, `CA`, `GB`, etc.).
205
+ > After setup, rename the entity IDs to exactly match:
206
+ > - `binary_sensor.hassl_<id>_workday`
207
+ > - `binary_sensor.hassl_<id>_not_holiday`
208
+ > where `<id>` matches the identifier used in your `.hassl` file (e.g., `us_ca`).
209
+
210
+ HASSL derives:
211
+ - `binary_sensor.hassl_holiday_<id>` → ON on holidays (even when they fall on weekends).
212
+
213
+ ### Truth table
214
+
215
+ | Day type | `hassl_<id>_workday` | `hassl_<id>_not_holiday` | `hassl_holiday_<id>` (derived) |
216
+ |------------------------------|-----------------------|---------------------------|---------------------------------|
217
+ | Tue (normal) | on | on | off |
218
+ | Sat (normal weekend) | off | on | off |
219
+ | Mon that’s an official holiday | off | off | on |
220
+ | Sat that’s an official holiday | off | off | on |
221
+
222
+ This distinction lets you build precise schedules like:
223
+ ```hassl
224
+ holidays us_ca:
225
+ country="US", province="CA"
226
+
227
+ schedule master_wake:
228
+ on weekdays 06:00–22:00 except holidays us_ca;
229
+ on weekends 08:00–22:00;
230
+ on holidays us_ca 09:00–22:00;
231
+ ```
232
+
233
+ 🧩 **Note:**
234
+ Both sensors must be created manually in the Home Assistant UI — integrations can’t be defined in YAML.
235
+ Once created, HASSL automatically references them in generated automations.
236
+
237
+ ---
238
+
239
+ ## ⚗️ Experimental: Date & Month Range Schedules
240
+
241
+ HASSL v0.3.1 includes early support for:
242
+
243
+ ```hassl
244
+ on months Jun–Aug 07:00–22:00;
245
+ on dates 12-24..01-02 06:00–20:00;
246
+ ```
139
247
 
140
- For full grammar and detailed semantics, see the [HASSL Language Specification](./HASSL_Specification.md).
248
+ These may compile successfully but are **not yet validated in production**.
249
+ They’re marked **experimental** and will be verified after template automation support (v0.4 milestone).
141
250
 
142
- For a hands-on guide, check out the [Quickstart](./quickstart.md).
251
+ ---
252
+
253
+ ## 📚 Documentation
254
+
255
+ - [Quickstart Guide](./quickstart.md)
256
+ - [Language Specification](./Hassl_spec.md)
143
257
 
144
258
  ---
145
259
 
146
260
  ## 🧩 Contributing
147
261
 
148
- Contributions, tests, and ideas welcome!\
262
+ Contributions, tests, and ideas welcome!
149
263
  To run tests locally:
150
264
 
151
265
  ```bash
@@ -158,10 +272,9 @@ Please open pull requests for grammar improvements, new device domains, or sched
158
272
 
159
273
  ## 📄 License
160
274
 
161
- MIT License © 2025\
275
+ MIT License © 2025
162
276
  Created and maintained by [@adanowitz](https://github.com/adanowitz)
163
277
 
164
278
  ---
165
279
 
166
280
  **HASSL** — simple, reliable, human-readable automations for Home Assistant.
167
-
@@ -0,0 +1,23 @@
1
+ hassl/__init__.py,sha256=r4xAFihOf72W9TD-lpMi6ntWSTKTP2SlzKP1ytkjRbI,22
2
+ hassl/cli.py,sha256=TSUvkoAg7ck1vlDUhC2sv_1NSetksdVGuOv-P9wrKxA,15762
3
+ hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ hassl/ast/nodes.py,sha256=U_UTmWS87laFuFX39mmzkk43JnGx7NEyNbkSomfTzSM,2704
5
+ hassl/codegen/__init__.py,sha256=NgEw86oHlsk7cQrHz8Ttrtp68Cm7WKceTWRr02SDfjo,854
6
+ hassl/codegen/generate.py,sha256=JmVBz8xhuPHosch0lhh8xU6tmdCsAl-qVWoY7hQxjow,206
7
+ hassl/codegen/init.py,sha256=kMfi_Us_7c_T35OK_jo8eqb79lxNV-dToO9-iAb5fHI,55
8
+ hassl/codegen/package.py,sha256=sGG40gonOoK3QJphO62Bju6WqFqfTT3XNx37QPHlb2w,42457
9
+ hassl/codegen/rules_min.py,sha256=LbLrpJ_2TV_FJlHB17TcuBaqfbLl97VUKzLck3s9KXo,29557
10
+ hassl/codegen/yaml_emit.py,sha256=VTNnR_uvSSqsL7kX5NyXuPUZh5FK36a_sUFsRyrQOS8,2207
11
+ hassl/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ hassl/parser/hassl.lark,sha256=-At0mQQiUXxQU2aB1pxrk1WfQdQiUJeH7Ev8ada7kLg,5637
13
+ hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
14
+ hassl/parser/transform.py,sha256=UXiUQBT3oMkFVJwFWA8X3L_Ds7PzZle7ZMBVVNSmp7Y,26332
15
+ hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ hassl/semantics/analyzer.py,sha256=M8pQTFRYSpAyBo-ctIRuT1DBb_WZsGs7hjT4-pHPLjA,18066
17
+ hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
18
+ hassl-0.3.1.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
19
+ hassl-0.3.1.dist-info/METADATA,sha256=TYCCs2uU-7mRXyvq2_pkMLbVMQY_VTqW5Oi5i0msaJg,9006
20
+ hassl-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ hassl-0.3.1.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
22
+ hassl-0.3.1.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
23
+ hassl-0.3.1.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- hassl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- hassl/cli.py,sha256=yCtfMQHzKCmORDypwxtbGQywLvdERLo3BLYSC2WUxu8,1522
3
- hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- hassl/ast/nodes.py,sha256=HINbu3o_hEMDD5G9TgYeqAev-Af0Vacqc_gwQQnpiy0,762
5
- hassl/codegen/__init__.py,sha256=NgEw86oHlsk7cQrHz8Ttrtp68Cm7WKceTWRr02SDfjo,854
6
- hassl/codegen/package.py,sha256=9dcIjszhGXV26Hwr_rKHLz6yYbElyIegfhlXGi5Q0sY,16612
7
- hassl/codegen/rules_min.py,sha256=2ko9bFRZujXw7lt5k8L6UbfmTwS2PCWfub5M6Lwkg4I,28330
8
- hassl/codegen/yaml_emit.py,sha256=VTNnR_uvSSqsL7kX5NyXuPUZh5FK36a_sUFsRyrQOS8,2207
9
- hassl/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- hassl/parser/hassl.lark,sha256=YIeY8ncd6nwBzS54A5JbvLhRiQd0u6ftea4nZac4_cA,3361
11
- hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
12
- hassl/parser/transform.py,sha256=dNTyPW9sOw09vBfvWMFSRRiTnOzZQWJvISV1o1aouD4,9481
13
- hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- hassl/semantics/analyzer.py,sha256=1x2bAJXj8hsT_IBoxgfTFV0MmVMXcfjqzl_gWo-WuI8,5195
15
- hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
16
- hassl-0.2.1.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
17
- hassl-0.2.1.dist-info/METADATA,sha256=Z43GbkiT3Sy-Lx3O5eQv8iUdcadYiIx73xac624-sMU,4929
18
- hassl-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- hassl-0.2.1.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
20
- hassl-0.2.1.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
21
- hassl-0.2.1.dist-info/RECORD,,
File without changes