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.
- hassl/__init__.py +1 -0
- hassl/ast/nodes.py +56 -4
- hassl/cli.py +349 -19
- hassl/codegen/generate.py +6 -0
- hassl/codegen/init.py +3 -0
- hassl/codegen/package.py +605 -30
- hassl/codegen/rules_min.py +204 -196
- hassl/parser/hassl.lark +75 -8
- hassl/parser/transform.py +505 -81
- hassl/semantics/analyzer.py +298 -22
- {hassl-0.2.1.dist-info → hassl-0.3.1.dist-info}/METADATA +123 -10
- hassl-0.3.1.dist-info/RECORD +23 -0
- hassl-0.2.1.dist-info/RECORD +0 -21
- {hassl-0.2.1.dist-info → hassl-0.3.1.dist-info}/WHEEL +0 -0
- {hassl-0.2.1.dist-info → hassl-0.3.1.dist-info}/entry_points.txt +0 -0
- {hassl-0.2.1.dist-info → hassl-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {hassl-0.2.1.dist-info → hassl-0.3.1.dist-info}/top_level.txt +0 -0
hassl/semantics/analyzer.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
from typing import Dict, List, Any, Optional
|
|
3
|
-
from ..ast.nodes import
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+

|
|
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
|
-
- `
|
|
133
|
-
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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,,
|
hassl-0.2.1.dist-info/RECORD
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|