hassl 0.3.2__py3-none-any.whl → 0.4.0__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 +16 -1
- hassl/cli.py +54 -188
- hassl/codegen/package.py +6 -2
- hassl/codegen/rules_min.py +13 -19
- hassl/parser/hassl.lark +62 -28
- hassl/parser/transform.py +110 -28
- hassl/semantics/analyzer.py +185 -8
- {hassl-0.3.2.dist-info → hassl-0.4.0.dist-info}/METADATA +6 -6
- hassl-0.4.0.dist-info/RECORD +23 -0
- {hassl-0.3.2.dist-info → hassl-0.4.0.dist-info}/WHEEL +1 -1
- hassl-0.3.2.dist-info/RECORD +0 -23
- {hassl-0.3.2.dist-info → hassl-0.4.0.dist-info}/entry_points.txt +0 -0
- {hassl-0.3.2.dist-info → hassl-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {hassl-0.3.2.dist-info → hassl-0.4.0.dist-info}/top_level.txt +0 -0
hassl/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.4.0"
|
hassl/ast/nodes.py
CHANGED
|
@@ -58,6 +58,20 @@ class Schedule:
|
|
|
58
58
|
windows: List[ScheduleWindow] = field(default_factory=list)
|
|
59
59
|
private: bool = False
|
|
60
60
|
|
|
61
|
+
@dataclass
|
|
62
|
+
class TemplateDecl:
|
|
63
|
+
kind: str # "rule" | "sync" | "schedule"
|
|
64
|
+
name: str
|
|
65
|
+
params: list = field(default_factory=list) # [{"name": "light", "default": ...}, ...]
|
|
66
|
+
body: Any = None # Rule | Sync | Schedule body shape
|
|
67
|
+
private: bool = False
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class UseTemplate:
|
|
71
|
+
name: str # template name being referenced
|
|
72
|
+
args: list = field(default_factory=list) # ["pos1", {"name":"kw","value":...}, ...]
|
|
73
|
+
as_name: Optional[str] = None
|
|
74
|
+
|
|
61
75
|
@dataclass
|
|
62
76
|
class Rule:
|
|
63
77
|
name: str
|
|
@@ -75,7 +89,8 @@ class Program:
|
|
|
75
89
|
def to_dict(self):
|
|
76
90
|
def enc(x):
|
|
77
91
|
if isinstance(x, (Alias, Sync, Rule, IfClause, Schedule,
|
|
78
|
-
HolidaySet, ScheduleWindow, PeriodSelector
|
|
92
|
+
HolidaySet, ScheduleWindow, PeriodSelector,
|
|
93
|
+
TemplateDecl, UseTemplate)):
|
|
79
94
|
d = asdict(x); d["type"] = x.__class__.__name__; return d
|
|
80
95
|
return x
|
|
81
96
|
return {
|
hassl/cli.py
CHANGED
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from typing import Dict, Tuple, List
|
|
5
5
|
from .parser.loader import load_grammar_text
|
|
6
6
|
from .parser.transform import HasslTransformer
|
|
7
|
-
from .ast.nodes import Program, Alias, Schedule
|
|
7
|
+
from .ast.nodes import Program, Alias, Schedule, TemplateDecl
|
|
8
8
|
from lark import Lark
|
|
9
9
|
from .semantics import analyzer as sem_analyzer
|
|
10
10
|
from .semantics.analyzer import analyze
|
|
@@ -85,6 +85,12 @@ def _collect_public_exports(prog: Program, pkg: str) -> Dict[Tuple[str,str,str],
|
|
|
85
85
|
name = s.get("name")
|
|
86
86
|
if isinstance(name, str) and name.strip():
|
|
87
87
|
out[(pkg, "schedule", name)] = Schedule(name=name, clauses=s.get("clauses", []) or [], private=False)
|
|
88
|
+
|
|
89
|
+
# Templates (public only)
|
|
90
|
+
for s in prog.statements:
|
|
91
|
+
if isinstance(s, TemplateDecl) and not getattr(s, "private", False):
|
|
92
|
+
out[(pkg, "template", s.name)] = s
|
|
93
|
+
|
|
88
94
|
return out
|
|
89
95
|
|
|
90
96
|
def _scan_hassl_files(path: Path) -> List[Path]:
|
|
@@ -97,40 +103,62 @@ def _module_to_path(module_root: Path, module: str) -> Path:
|
|
|
97
103
|
|
|
98
104
|
def _ensure_imports_loaded(programs, module_root: Path):
|
|
99
105
|
"""If imported packages aren't parsed yet, try to load their .hassl files from module_root."""
|
|
100
|
-
#
|
|
106
|
+
# Track both package names (from parsed files) and module ids (from import statements)
|
|
101
107
|
known_pkgs = {pkg for _, _, pkg in programs}
|
|
108
|
+
seen_modules: set[str] = set() # abs_mod we've already attempted/loaded
|
|
109
|
+
seen_paths: set[Path] = set(p for p, _, _ in programs)
|
|
110
|
+
|
|
102
111
|
added = True
|
|
103
112
|
while added:
|
|
104
113
|
added = False
|
|
105
|
-
|
|
114
|
+
# iterate over a snapshot; we'll append to programs
|
|
115
|
+
for importer_path, prog, importer_pkg in list(programs):
|
|
106
116
|
for imp in getattr(prog, "imports", []) or []:
|
|
107
117
|
if not isinstance(imp, dict) or imp.get("type") != "import":
|
|
108
118
|
continue
|
|
119
|
+
|
|
109
120
|
raw_mod = imp.get("module", "")
|
|
110
|
-
if not raw_mod:
|
|
121
|
+
if not raw_mod or not module_root:
|
|
111
122
|
continue
|
|
112
|
-
|
|
123
|
+
|
|
113
124
|
abs_mod = _normalize_module(importer_pkg, raw_mod)
|
|
114
|
-
|
|
125
|
+
|
|
126
|
+
# guard: avoid re-trying the same module or self-import
|
|
127
|
+
if abs_mod in seen_modules or abs_mod == importer_pkg:
|
|
115
128
|
continue
|
|
116
|
-
|
|
129
|
+
|
|
130
|
+
# If we already have a program whose package == abs_mod, skip
|
|
131
|
+
if abs_mod in known_pkgs:
|
|
132
|
+
seen_modules.add(abs_mod)
|
|
117
133
|
continue
|
|
134
|
+
|
|
118
135
|
candidate = _module_to_path(module_root, abs_mod)
|
|
119
|
-
if candidate.exists():
|
|
120
|
-
print(f"[hasslc] Autoload candidate FOUND for '{abs_mod}': {candidate}")
|
|
121
|
-
with open(candidate, "r", encoding="utf-8") as f:
|
|
122
|
-
text = f.read()
|
|
123
|
-
p = parse_hassl(text)
|
|
124
|
-
# force package to declared or derived (declared will win)
|
|
125
|
-
# If the file declared a package, keep it. Otherwise, assign the resolved module id.
|
|
126
|
-
pkg_name = p.package or abs_mod
|
|
127
|
-
p.package = pkg_name
|
|
128
|
-
programs.append((candidate, p, pkg_name))
|
|
129
|
-
known_pkgs.add(pkg_name)
|
|
130
|
-
added = True
|
|
131
|
-
else:
|
|
136
|
+
if not candidate.exists():
|
|
132
137
|
print(f"[hasslc] Autoload candidate MISS for '{abs_mod}': {candidate}")
|
|
138
|
+
seen_modules.add(abs_mod) # mark as tried to avoid spamming
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if candidate in seen_paths:
|
|
142
|
+
# We’ve already parsed this exact file; mark its module and move on
|
|
143
|
+
print(f"[hasslc] Autoload candidate SKIP (already loaded) for '{abs_mod}': {candidate}")
|
|
144
|
+
seen_modules.add(abs_mod)
|
|
145
|
+
continue
|
|
133
146
|
|
|
147
|
+
print(f"[hasslc] Autoload candidate FOUND for '{abs_mod}': {candidate}")
|
|
148
|
+
with open(candidate, "r", encoding="utf-8") as f:
|
|
149
|
+
text = f.read()
|
|
150
|
+
|
|
151
|
+
p = parse_hassl(text)
|
|
152
|
+
# prefer declared package; otherwise bind to module id
|
|
153
|
+
pkg_name = p.package or abs_mod
|
|
154
|
+
p.package = pkg_name
|
|
155
|
+
|
|
156
|
+
programs.append((candidate, p, pkg_name))
|
|
157
|
+
known_pkgs.add(pkg_name)
|
|
158
|
+
seen_modules.add(abs_mod)
|
|
159
|
+
seen_paths.add(candidate)
|
|
160
|
+
added = True
|
|
161
|
+
|
|
134
162
|
def main():
|
|
135
163
|
print("[hasslc] Using CLI file:", __file__)
|
|
136
164
|
ap = argparse.ArgumentParser(prog="hasslc", description="HASSL Compiler")
|
|
@@ -197,174 +225,12 @@ def main():
|
|
|
197
225
|
|
|
198
226
|
# Also drop a cross-project export table for debugging
|
|
199
227
|
with open(out_root / "DEBUG_exports.json", "w", encoding="utf-8") as fp:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
import argparse
|
|
207
|
-
import os, json, glob
|
|
208
|
-
from pathlib import Path
|
|
209
|
-
from typing import Dict, Tuple, List
|
|
210
|
-
from .parser.loader import load_grammar_text
|
|
211
|
-
from .parser.transform import HasslTransformer
|
|
212
|
-
from .ast.nodes import Program, Alias, Schedule
|
|
213
|
-
from lark import Lark
|
|
214
|
-
from .semantics import analyzer as sem_analyzer
|
|
215
|
-
from .semantics.analyzer import analyze
|
|
216
|
-
from .codegen.package import emit_package
|
|
217
|
-
from .codegen import generate as codegen_generate
|
|
218
|
-
|
|
219
|
-
def parse_hassl(text: str) -> Program:
|
|
220
|
-
grammar = load_grammar_text()
|
|
221
|
-
parser = Lark(grammar, start="start", parser="lalr", maybe_placeholders=False)
|
|
222
|
-
tree = parser.parse(text)
|
|
223
|
-
program = HasslTransformer().transform(tree)
|
|
224
|
-
return program
|
|
225
|
-
|
|
226
|
-
def _derive_package_name(prog: Program, src_path: Path, module_root: Path | None) -> str:
|
|
227
|
-
"""
|
|
228
|
-
If the source did not declare `package`, derive one from the path:
|
|
229
|
-
- If module_root is given and src_path is under it: use relative path (dots)
|
|
230
|
-
- Else: use file stem
|
|
231
|
-
"""
|
|
232
|
-
if getattr(prog, "package", None):
|
|
233
|
-
return prog.package # declared
|
|
234
|
-
if module_root:
|
|
235
|
-
try:
|
|
236
|
-
rel = src_path.resolve().relative_to(module_root.resolve())
|
|
237
|
-
parts = list(rel.with_suffix("").parts)
|
|
238
|
-
if parts:
|
|
239
|
-
return ".".join(parts)
|
|
240
|
-
except Exception:
|
|
241
|
-
pass
|
|
242
|
-
return src_path.stem
|
|
243
|
-
|
|
244
|
-
def _collect_public_exports(prog: Program, pkg: str) -> Dict[Tuple[str,str,str], object]:
|
|
245
|
-
"""
|
|
246
|
-
Build (pkg, kind, name) -> node for public alias/schedule in a single Program.
|
|
247
|
-
Accepts both Schedule nodes and transformer dicts {"type":"schedule_decl",...}.
|
|
248
|
-
"""
|
|
249
|
-
out: Dict[Tuple[str,str,str], object] = {}
|
|
250
|
-
# Aliases
|
|
251
|
-
for s in prog.statements:
|
|
252
|
-
if isinstance(s, Alias):
|
|
253
|
-
if not getattr(s, "private", False):
|
|
254
|
-
out[(pkg, "alias", s.name)] = s
|
|
255
|
-
# Schedules (either dicts from transformer or Schedule nodes)
|
|
256
|
-
for s in prog.statements:
|
|
257
|
-
if isinstance(s, Schedule):
|
|
258
|
-
if not getattr(s, "private", False):
|
|
259
|
-
out[(pkg, "schedule", s.name)] = s
|
|
260
|
-
elif isinstance(s, dict) and s.get("type") == "schedule_decl" and not s.get("private", False):
|
|
261
|
-
name = s.get("name")
|
|
262
|
-
if isinstance(name, str) and name.strip():
|
|
263
|
-
out[(pkg, "schedule", name)] = Schedule(name=name, clauses=s.get("clauses", []) or [], private=False)
|
|
264
|
-
return out
|
|
265
|
-
|
|
266
|
-
def _scan_hassl_files(path: Path) -> List[Path]:
|
|
267
|
-
if path.is_file():
|
|
268
|
-
return [path]
|
|
269
|
-
return [Path(p) for p in glob.glob(str(path / "**" / "*.hassl"), recursive=True)]
|
|
270
|
-
|
|
271
|
-
def _module_to_path(module_root: Path, module: str) -> Path:
|
|
272
|
-
return (module_root / Path(module.replace(".", "/"))).with_suffix(".hassl")
|
|
273
|
-
|
|
274
|
-
def _ensure_imports_loaded(programs, module_root: Path):
|
|
275
|
-
"""If imported packages aren't parsed yet, try to load their .hassl files from module_root."""
|
|
276
|
-
# programs: List[tuple[Path, Program, str]]
|
|
277
|
-
known_pkgs = {pkg for _, _, pkg in programs}
|
|
278
|
-
added = True
|
|
279
|
-
while added:
|
|
280
|
-
added = False
|
|
281
|
-
for _, prog, _pkg in list(programs):
|
|
282
|
-
for imp in getattr(prog, "imports", []) or []:
|
|
283
|
-
if not isinstance(imp, dict) or imp.get("type") != "import":
|
|
284
|
-
continue
|
|
285
|
-
mod = imp.get("module", "")
|
|
286
|
-
if not mod or mod in known_pkgs:
|
|
287
|
-
continue
|
|
288
|
-
if not module_root:
|
|
289
|
-
continue
|
|
290
|
-
candidate = _module_to_path(module_root, mod)
|
|
291
|
-
if candidate.exists():
|
|
292
|
-
print(f"[hasslc] Autoload candidate FOUND for '{mod}': {candidate}")
|
|
293
|
-
with open(candidate, "r", encoding="utf-8") as f:
|
|
294
|
-
text = f.read()
|
|
295
|
-
p = parse_hassl(text)
|
|
296
|
-
# force package to declared or derived (declared will win)
|
|
297
|
-
pkg_name = p.package or mod
|
|
298
|
-
p.package = pkg_name
|
|
299
|
-
programs.append((candidate, p, pkg_name))
|
|
300
|
-
known_pkgs.add(pkg_name)
|
|
301
|
-
added = True
|
|
302
|
-
else:
|
|
303
|
-
print(f"[hasslc] Autoload candidate MISS for '{mod}': {candidate}")
|
|
304
|
-
|
|
305
|
-
def main():
|
|
306
|
-
ap = argparse.ArgumentParser(prog="hasslc", description="HASSL Compiler")
|
|
307
|
-
ap.add_argument("input", help="Input .hassl file OR directory")
|
|
308
|
-
ap.add_argument("-o", "--out", default="./packages/out", help="Output directory root for HA package(s)")
|
|
309
|
-
ap.add_argument("--module-root", default=None, help="Optional root to derive package names from paths")
|
|
310
|
-
args = ap.parse_args()
|
|
311
|
-
|
|
312
|
-
in_path = Path(args.input)
|
|
313
|
-
out_root = Path(args.out)
|
|
314
|
-
module_root = Path(args.module_root).resolve() if args.module_root else None
|
|
315
|
-
|
|
316
|
-
src_files = _scan_hassl_files(in_path)
|
|
317
|
-
if not src_files:
|
|
318
|
-
raise SystemExit(f"[hasslc] No .hassl files found in {in_path}")
|
|
319
|
-
|
|
320
|
-
# Pass 0: parse all and assign/derive package names
|
|
321
|
-
programs: List[tuple[Path, Program, str]] = []
|
|
322
|
-
for p in src_files:
|
|
323
|
-
with open(p, "r", encoding="utf-8") as f:
|
|
324
|
-
text = f.read()
|
|
325
|
-
prog = parse_hassl(text)
|
|
326
|
-
pkg_name = _derive_package_name(prog, p, module_root)
|
|
327
|
-
try:
|
|
328
|
-
prog.package = pkg_name
|
|
329
|
-
except Exception:
|
|
330
|
-
pass
|
|
331
|
-
programs.append((p, prog, pkg_name))
|
|
332
|
-
|
|
333
|
-
# auto-load any missing imports from --module_root
|
|
334
|
-
_ensure_imports_loaded(programs, module_root)
|
|
335
|
-
|
|
336
|
-
# Pass 1: collect public exports across all programs
|
|
337
|
-
GLOBAL_EXPORTS: Dict[Tuple[str,str,str], object] = {}
|
|
338
|
-
for path, prog, pkg in programs:
|
|
339
|
-
GLOBAL_EXPORTS.update(_collect_public_exports(prog, pkg))
|
|
340
|
-
|
|
341
|
-
# publish global exports to analyzer
|
|
342
|
-
sem_analyzer.GLOBAL_EXPORTS = GLOBAL_EXPORTS
|
|
343
|
-
|
|
344
|
-
# Pass 2: analyze each program with global view
|
|
345
|
-
os.makedirs(out_root, exist_ok=True)
|
|
346
|
-
all_ir = []
|
|
347
|
-
for path, prog, pkg in programs:
|
|
348
|
-
print(f"[hasslc] Parsing {path} (package: {pkg})")
|
|
349
|
-
print("[hasslc] AST:", json.dumps(prog.to_dict(), indent=2))
|
|
350
|
-
ir = analyze(prog)
|
|
351
|
-
print("[hasslc] IR:", json.dumps(ir.to_dict(), indent=2))
|
|
352
|
-
all_ir.append((pkg, ir))
|
|
353
|
-
|
|
354
|
-
# Emit: per package subdir
|
|
355
|
-
for pkg, ir in all_ir:
|
|
356
|
-
pkg_dir = out_root / pkg.replace(".", "_")
|
|
357
|
-
os.makedirs(pkg_dir, exist_ok=True)
|
|
358
|
-
ir_dict = ir.to_dict() if hasattr(ir, "to_dict") else ir
|
|
359
|
-
codegen_generate(ir_dict, str(pkg_dir))
|
|
360
|
-
emit_package(ir, str(pkg_dir))
|
|
361
|
-
with open(pkg_dir / "DEBUG_ir.json", "w", encoding="utf-8") as dbg:
|
|
362
|
-
dbg.write(json.dumps(ir.to_dict(), indent=2))
|
|
363
|
-
print(f"[hasslc] Package written to {pkg_dir}")
|
|
364
|
-
|
|
365
|
-
# Also drop a cross-project export table for debugging
|
|
366
|
-
with open(out_root / "DEBUG_exports.json", "w", encoding="utf-8") as fp:
|
|
367
|
-
printable = {f"{k[0]}::{k[1]}::{k[2]}": ("Alias" if isinstance(v, Alias) else "Schedule") for k, v in GLOBAL_EXPORTS.items()}
|
|
228
|
+
def _kind(v):
|
|
229
|
+
if isinstance(v, Alias): return "Alias"
|
|
230
|
+
if isinstance(v, Schedule): return "Schedule"
|
|
231
|
+
if isinstance(v, TemplateDecl): return "Template"
|
|
232
|
+
return type(v).__name__
|
|
233
|
+
printable = {f"{k[0]}::{k[1]}::{k[2]}": _kind(v) for k, v in GLOBAL_EXPORTS.items()}
|
|
368
234
|
json.dump(printable, fp, indent=2)
|
|
369
235
|
print(f"[hasslc] Global exports index written to {out_root / 'DEBUG_exports.json'}")
|
|
370
236
|
|
hassl/codegen/package.py
CHANGED
|
@@ -812,9 +812,13 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
812
812
|
if isinstance(ts, dict):
|
|
813
813
|
return ts
|
|
814
814
|
if isinstance(ts, str):
|
|
815
|
+
v = ts.strip()
|
|
815
816
|
# accept "HH:MM" or "HH:MM:SS"
|
|
816
|
-
|
|
817
|
-
|
|
817
|
+
if len(v) == 5: # HH:MM
|
|
818
|
+
return {"kind":"clock","value": v}
|
|
819
|
+
if len(v) == 8: # HH:MM:SS
|
|
820
|
+
return {"kind":"clock","value": v}
|
|
821
|
+
return {"kind":"clock","value":"00:00"}
|
|
818
822
|
return {"kind":"clock","value":"00:00"}
|
|
819
823
|
start_ts = _coerce(raw_start)
|
|
820
824
|
end_ts = _coerce(raw_end)
|
hassl/codegen/rules_min.py
CHANGED
|
@@ -331,10 +331,15 @@ def _collect_schedules(ir: dict):
|
|
|
331
331
|
inline_by_rule = {}
|
|
332
332
|
use_by_rule = {}
|
|
333
333
|
|
|
334
|
-
# top-level declared schedules
|
|
334
|
+
# top-level declared schedules (legacy clauses)
|
|
335
335
|
schedules_obj = ir.get("schedules") or {}
|
|
336
336
|
if isinstance(schedules_obj, dict):
|
|
337
337
|
declared = {str(k): (v or []) for k, v in schedules_obj.items()}
|
|
338
|
+
# include window schedules as declared (no legacy clauses, but valid names)
|
|
339
|
+
windows_obj = ir.get("schedules_windows") or {}
|
|
340
|
+
if isinstance(windows_obj, dict):
|
|
341
|
+
for k in windows_obj.keys():
|
|
342
|
+
declared.setdefault(str(k), [])
|
|
338
343
|
|
|
339
344
|
# per-rule data
|
|
340
345
|
for rule in ir.get("rules", []):
|
|
@@ -359,7 +364,7 @@ def generate_rules(ir, outdir):
|
|
|
359
364
|
# collect helper keys we must ensure exist
|
|
360
365
|
ctx_inputs = {}
|
|
361
366
|
|
|
362
|
-
# package slug for
|
|
367
|
+
# package slug for output file naming
|
|
363
368
|
pkg = _pkg_slug(outdir)
|
|
364
369
|
|
|
365
370
|
# --- schedules collection ---
|
|
@@ -385,16 +390,14 @@ def generate_rules(ir, outdir):
|
|
|
385
390
|
for nm in (use_by_rule.get(rname, []) or []):
|
|
386
391
|
base = str(nm).split(".")[-1]
|
|
387
392
|
if base not in declared_base_names and (base not in exported_sched_pkgs):
|
|
388
|
-
expected_sensor = _schedule_sensor(base, pkg)
|
|
389
393
|
raise ValueError(
|
|
390
394
|
"HASSL: schedule reference not found. "
|
|
391
395
|
f"Rule '{rname}' uses schedule '{nm}', but no schedule named '{base}' "
|
|
392
|
-
|
|
396
|
+
"was declared in this package.\n\n"
|
|
393
397
|
"Declare it with:\n"
|
|
394
398
|
f" schedule {base}:\n"
|
|
395
399
|
" enable from <start> to <end>;\n\n"
|
|
396
400
|
"Or ensure the schedule is declared in the same package.\n"
|
|
397
|
-
f"(If you expected a sensor, codegen looks for: {expected_sensor})"
|
|
398
401
|
)
|
|
399
402
|
|
|
400
403
|
# NOTE: No helper creation here — package.py owns schedule sensors.
|
|
@@ -415,15 +418,6 @@ def generate_rules(ir, outdir):
|
|
|
415
418
|
if rule_gates:
|
|
416
419
|
for g in rule_gates:
|
|
417
420
|
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
421
|
if not ents:
|
|
428
422
|
continue
|
|
429
423
|
if len(ents) == 1:
|
|
@@ -593,11 +587,11 @@ def generate_rules(ir, outdir):
|
|
|
593
587
|
"data": {"entity_id": full, "value": str(val)}
|
|
594
588
|
})
|
|
595
589
|
else:
|
|
596
|
-
#
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
590
|
+
# Unknown action type: log and skip (keeps generator robust)
|
|
591
|
+
act_list.append({
|
|
592
|
+
"service": "logbook.log",
|
|
593
|
+
"data": {"name": "HASSL", "message": f"Unhandled action type: {act.get('type')}"}
|
|
594
|
+
})
|
|
601
595
|
|
|
602
596
|
conds = [gate_cond] + sched_conds + [cond_ha]
|
|
603
597
|
if qual_cond:
|
hassl/parser/hassl.lark
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// + additive schedule support
|
|
3
3
|
// + packages/imports + private visibility
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
start: stmt*
|
|
6
7
|
|
|
7
8
|
stmt: package_decl
|
|
@@ -11,6 +12,8 @@ stmt: package_decl
|
|
|
11
12
|
| rule
|
|
12
13
|
| schedule_decl
|
|
13
14
|
| holidays_decl
|
|
15
|
+
| template_decl
|
|
16
|
+
| use_template_stmt
|
|
14
17
|
|
|
15
18
|
// --- Aliases ---
|
|
16
19
|
alias: PRIVATE? "alias" CNAME "=" entity //can mark private
|
|
@@ -18,11 +21,6 @@ alias: PRIVATE? "alias" CNAME "=" entity //can mark private
|
|
|
18
21
|
// --- Sync ---
|
|
19
22
|
sync: "sync" synctype "[" entity_list "]" "as" CNAME syncopts?
|
|
20
23
|
|
|
21
|
-
ONOFF: "onoff"
|
|
22
|
-
DIMMER: "dimmer"
|
|
23
|
-
ATTRIBUTE: "attribute"
|
|
24
|
-
SHARED: "shared"
|
|
25
|
-
ALL: "all"
|
|
26
24
|
synctype: ONOFF | DIMMER | ATTRIBUTE | SHARED | ALL
|
|
27
25
|
|
|
28
26
|
syncopts: "{" ("invert" ":" entity_list) "}"
|
|
@@ -74,7 +72,7 @@ assign: CNAME "=" STATE ("for" dur)?
|
|
|
74
72
|
attr_assign: CNAME "." CNAME ("." CNAME)* "." CNAME "=" NUMBER
|
|
75
73
|
| CNAME "." CNAME "=" NUMBER
|
|
76
74
|
|
|
77
|
-
waitact: "wait" "(" condition "for" dur ")" action
|
|
75
|
+
waitact: "wait" "(" condition "for" (dur | CNAME | STRING) ")" action
|
|
78
76
|
// condition used by wait(): allow expr + optional qualifier inside the parens
|
|
79
77
|
condition: expr qualifier?
|
|
80
78
|
|
|
@@ -88,20 +86,6 @@ UNIT: "ms" | "s" | "m" | "h" | "d"
|
|
|
88
86
|
// Timepoint placeholder (kept simple)
|
|
89
87
|
timepoint: CNAME
|
|
90
88
|
|
|
91
|
-
SCHEDULE: "schedule"
|
|
92
|
-
USE: "use"
|
|
93
|
-
FROM: "from"
|
|
94
|
-
TO: "to"
|
|
95
|
-
UNTIL: "until"
|
|
96
|
-
ENABLE: "enable"
|
|
97
|
-
DISABLE: "disable"
|
|
98
|
-
SUNRISE: "sunrise"
|
|
99
|
-
SUNSET: "sunset"
|
|
100
|
-
PRIVATE: "private"
|
|
101
|
-
|
|
102
|
-
TIME_HHMM: /[0-2]?\d:[0-5]\d/
|
|
103
|
-
OFFSET: /[+-]\d+(ms|s|m|h|d)/
|
|
104
|
-
|
|
105
89
|
// Reusable named schedule declarations
|
|
106
90
|
schedule_decl: PRIVATE? SCHEDULE CNAME ":" schedule_clause+
|
|
107
91
|
|
|
@@ -132,9 +116,6 @@ MMDD: /\d{2}-\d{2}/
|
|
|
132
116
|
ymd_range: YMD ".." YMD
|
|
133
117
|
YMD: /\d{4}-\d{2}-\d{2}/
|
|
134
118
|
|
|
135
|
-
WEEKDAYS: "weekdays"
|
|
136
|
-
WEEKENDS: "weekends"
|
|
137
|
-
DAILY: "daily"
|
|
138
119
|
day_selector: WEEKDAYS | WEEKENDS | DAILY
|
|
139
120
|
time_range: TIME_HHMM "-" TIME_HHMM
|
|
140
121
|
holiday_mod: "except" "holidays" CNAME
|
|
@@ -144,7 +125,7 @@ schedule_end: TO time_spec -> schedule_to
|
|
|
144
125
|
| UNTIL time_spec -> schedule_until
|
|
145
126
|
|
|
146
127
|
// Rule-level usage
|
|
147
|
-
rule_schedule_use: SCHEDULE USE name_list ";"
|
|
128
|
+
rule_schedule_use: SCHEDULE USE name_list ";"?
|
|
148
129
|
// allow qualified (pkg.symbol) via existing 'entity' rule
|
|
149
130
|
name_list: name ("," name)*
|
|
150
131
|
name: CNAME | entity
|
|
@@ -166,12 +147,41 @@ member: entity | CNAME
|
|
|
166
147
|
|
|
167
148
|
entity: CNAME ("." CNAME)+
|
|
168
149
|
|
|
150
|
+
|
|
169
151
|
// ---- Lexer ----
|
|
170
|
-
|
|
171
|
-
|
|
152
|
+
// Order matters: keyword terminals first so they take precedence over CNAME
|
|
153
|
+
SCHEDULE: "schedule"
|
|
154
|
+
USE: "use"
|
|
155
|
+
FROM: "from"
|
|
156
|
+
TO: "to"
|
|
157
|
+
UNTIL: "until"
|
|
158
|
+
ENABLE: "enable"
|
|
159
|
+
DISABLE: "disable"
|
|
160
|
+
SUNRISE: "sunrise"
|
|
161
|
+
SUNSET: "sunset"
|
|
162
|
+
PRIVATE: "private"
|
|
163
|
+
TEMPLATE: "template"
|
|
164
|
+
|
|
165
|
+
ONOFF: "onoff"
|
|
166
|
+
DIMMER: "dimmer"
|
|
167
|
+
ATTRIBUTE: "attribute"
|
|
168
|
+
SHARED: "shared"
|
|
169
|
+
ALL: "all"
|
|
170
|
+
WEEKDAYS: "weekdays"
|
|
171
|
+
WEEKENDS: "weekends"
|
|
172
|
+
DAILY: "daily"
|
|
173
|
+
|
|
172
174
|
%import common.ESCAPED_STRING -> STRING
|
|
173
175
|
%import common.INT
|
|
174
176
|
%import common.SIGNED_NUMBER
|
|
177
|
+
%import common.WS
|
|
178
|
+
|
|
179
|
+
// Override CNAME after keywords, so keywords win
|
|
180
|
+
CNAME: /[a-zA-Z_][a-zA-Z0-9_]*/
|
|
181
|
+
|
|
182
|
+
TIME_HHMM: /[0-2]?\d:[0-5]\d/
|
|
183
|
+
OFFSET: /[+-]\d+(ms|s|m|h|d)/
|
|
184
|
+
|
|
175
185
|
LINE_COMMENT: /\/\/[^\n]*/
|
|
176
186
|
%ignore LINE_COMMENT
|
|
177
187
|
%ignore WS
|
|
@@ -192,7 +202,7 @@ import_tail: ".*"
|
|
|
192
202
|
import_list: import_item ("," import_item)*
|
|
193
203
|
import_item: CNAME ("as" CNAME)?
|
|
194
204
|
|
|
195
|
-
// ---- Holidays declaration
|
|
205
|
+
// ---- Holidays declaration ----
|
|
196
206
|
// Example:
|
|
197
207
|
// holidays us_ca:
|
|
198
208
|
// country="US", province="CA", add=["2025-11-28"], remove=["2025-12-26"]
|
|
@@ -207,4 +217,28 @@ daylist: DAY ("," DAY)*
|
|
|
207
217
|
excludelist: ("sat"|"sun"|"holiday") ("," ("sat"|"sun"|"holiday"))*
|
|
208
218
|
DAY.2: "mon"|"tue"|"wed"|"thu"|"fri"|"sat"|"sun"
|
|
209
219
|
DATESTR: /"\d{4}-\d{2}-\d{2}"/
|
|
210
|
-
datestr_list: DATESTR ("," DATESTR)*
|
|
220
|
+
datestr_list: DATESTR ("," DATESTR)*
|
|
221
|
+
|
|
222
|
+
// === Template
|
|
223
|
+
template_decl: PRIVATE? TEMPLATE template_kind CNAME "(" template_params? ")" ":" template_body
|
|
224
|
+
template_kind: "rule" | "sync" | "schedule"
|
|
225
|
+
template_params: template_param ("," template_param)*
|
|
226
|
+
template_param: CNAME ("=" template_default)?
|
|
227
|
+
template_default: NUMBER | STRING | CNAME
|
|
228
|
+
|
|
229
|
+
template_body: rule_body -> template_rule_body
|
|
230
|
+
| sync_body -> template_sync_body
|
|
231
|
+
| schedule_body -> template_schedule_body
|
|
232
|
+
|
|
233
|
+
// --- Bodies used by template_body (define these ONCE) ---
|
|
234
|
+
rule_body: rule_clause+ // clauses without 'rule NAME:'
|
|
235
|
+
sync_body: synctype "[" entity_list "]" syncopts?
|
|
236
|
+
schedule_body: schedule_clause+ // clauses without 'schedule NAME:'
|
|
237
|
+
|
|
238
|
+
// Reuse existing pieces parsed elsewhere (no duplicates below this line)
|
|
239
|
+
|
|
240
|
+
// --- Template invocation ---
|
|
241
|
+
use_template_stmt: "use" "template" CNAME "(" call_args? ")" ("as" CNAME)?
|
|
242
|
+
call_args: call_arg ("," call_arg)*
|
|
243
|
+
call_arg: (CNAME "=" value) | value
|
|
244
|
+
value: NUMBER | STRING | entity | CNAME
|
hassl/parser/transform.py
CHANGED
|
@@ -92,6 +92,11 @@ class HasslTransformer(Transformer):
|
|
|
92
92
|
def import_tail(self, *args):
|
|
93
93
|
if len(args) == 1 and isinstance(args[0], Token) and str(args[0]) == ".*":
|
|
94
94
|
return ("glob", [], None)
|
|
95
|
+
# Some parses drop the literal "as" token; treat a single CNAME as alias target.
|
|
96
|
+
if len(args) == 1 and isinstance(args[0], (Token, str)):
|
|
97
|
+
val = str(args[0])
|
|
98
|
+
if val and val != ".*":
|
|
99
|
+
return ("alias", [], val)
|
|
95
100
|
if len(args) == 2:
|
|
96
101
|
a0, a1 = args
|
|
97
102
|
if isinstance(a0, Token) and str(a0) == ":":
|
|
@@ -109,6 +114,107 @@ class HasslTransformer(Transformer):
|
|
|
109
114
|
return {"name": str(parts[0]), "as": None}
|
|
110
115
|
return {"name": str(parts[0]), "as": str(parts[-1])}
|
|
111
116
|
|
|
117
|
+
# ============ Templates (NEW) ============
|
|
118
|
+
# These are no-ops unless your grammar includes template_* rules.
|
|
119
|
+
# They simply build nodes and push them into self.stmts so the analyzer can expand them.
|
|
120
|
+
|
|
121
|
+
# template_decl: PRIVATE? TEMPLATE template_kind CNAME "(" template_params? ")" ":" template_body
|
|
122
|
+
def template_decl(self, *parts):
|
|
123
|
+
private = any(isinstance(p, Token) and p.type == "PRIVATE" for p in parts)
|
|
124
|
+
kind = next((str(p) for p in parts if isinstance(p, str) and p in ("rule","sync","schedule")), "rule")
|
|
125
|
+
name = None
|
|
126
|
+
params = []
|
|
127
|
+
body = None
|
|
128
|
+
seen_paren = False
|
|
129
|
+
for p in parts:
|
|
130
|
+
if isinstance(p, Token) and p.type == "CNAME" and name is None:
|
|
131
|
+
name = str(p); continue
|
|
132
|
+
# parameter list arrives as a python list (via template_params)
|
|
133
|
+
if isinstance(p, list) and p and isinstance(p[0], dict) and "name" in p[0]:
|
|
134
|
+
params = p; continue
|
|
135
|
+
# the first non-token/non-list after ':' should be the body node (Rule/Sync/Schedule body)
|
|
136
|
+
if not isinstance(p, (list, Token, str)) and p is not None:
|
|
137
|
+
body = p
|
|
138
|
+
t = nodes.TemplateDecl(kind=kind, name=str(name) if name else "", params=params, body=body, private=private)
|
|
139
|
+
self.stmts.append(t)
|
|
140
|
+
return t
|
|
141
|
+
|
|
142
|
+
def template_kind(self, *toks):
|
|
143
|
+
if not toks:
|
|
144
|
+
return "rule"
|
|
145
|
+
tok = toks[0]
|
|
146
|
+
return str(tok)
|
|
147
|
+
|
|
148
|
+
def template_params(self, *items):
|
|
149
|
+
return list(items)
|
|
150
|
+
|
|
151
|
+
# template_param: CNAME ("=" template_default)?
|
|
152
|
+
def template_param(self, name, default=None):
|
|
153
|
+
return {"name": str(name), "default": default}
|
|
154
|
+
|
|
155
|
+
# template_default: NUMBER | STRING | CNAME
|
|
156
|
+
def template_default(self, val):
|
|
157
|
+
return _atom(val)
|
|
158
|
+
|
|
159
|
+
# template_body variants – just forward the inner node as-is
|
|
160
|
+
def template_rule_body(self, body): return body
|
|
161
|
+
def template_sync_body(self, body): return body
|
|
162
|
+
def template_schedule_body(self, body): return body
|
|
163
|
+
|
|
164
|
+
# New: rule_body / schedule_body nodes for templates
|
|
165
|
+
def rule_body(self, *clauses):
|
|
166
|
+
# Produce a Rule node with empty name; analyzer will rename on instantiation
|
|
167
|
+
return nodes.Rule(name="", clauses=list(clauses))
|
|
168
|
+
|
|
169
|
+
def schedule_body(self, *clauses):
|
|
170
|
+
# Produce a Schedule node with empty name; analyzer will rename on instantiation
|
|
171
|
+
return nodes.Schedule(name="", clauses=list(clauses), windows=[])
|
|
172
|
+
|
|
173
|
+
def sync_body(self, synctype, members, syncopts=None):
|
|
174
|
+
invert = syncopts if isinstance(syncopts, list) else []
|
|
175
|
+
# name is filled later if you 'use template ... as <name>'
|
|
176
|
+
return nodes.Sync(kind=str(synctype), members=members, name="", invert=invert)
|
|
177
|
+
|
|
178
|
+
# use_template_stmt: "use" "template" CNAME "(" call_args? ")" ("as" CNAME)?
|
|
179
|
+
def use_template_stmt(self, *parts):
|
|
180
|
+
name = None
|
|
181
|
+
as_name = None
|
|
182
|
+
args = []
|
|
183
|
+
seen_as = False
|
|
184
|
+
for p in parts:
|
|
185
|
+
if isinstance(p, Token) and p.type == "CNAME":
|
|
186
|
+
if name is None:
|
|
187
|
+
name = str(p)
|
|
188
|
+
elif seen_as and as_name is None:
|
|
189
|
+
as_name = str(p)
|
|
190
|
+
# else: ignore stray CNAMEs (shouldn't happen if grammar is strict)
|
|
191
|
+
continue
|
|
192
|
+
if isinstance(p, list):
|
|
193
|
+
# call_args comes in as a list
|
|
194
|
+
args = p
|
|
195
|
+
continue
|
|
196
|
+
if isinstance(p, str) and p == "as":
|
|
197
|
+
seen_as = True
|
|
198
|
+
continue
|
|
199
|
+
u = nodes.UseTemplate(name=str(name) if name else "", args=args, as_name=as_name)
|
|
200
|
+
self.stmts.append(u)
|
|
201
|
+
return u
|
|
202
|
+
|
|
203
|
+
# call_args: call_arg ("," call_arg)*
|
|
204
|
+
def call_args(self, *items):
|
|
205
|
+
return list(items)
|
|
206
|
+
|
|
207
|
+
# call_arg: (CNAME "=" value) | value
|
|
208
|
+
def call_arg(self, *parts):
|
|
209
|
+
if len(parts) == 1:
|
|
210
|
+
return parts[0]
|
|
211
|
+
# name "=" value
|
|
212
|
+
return {"name": str(parts[0]), "value": parts[-1]}
|
|
213
|
+
|
|
214
|
+
# value: NUMBER | STRING | entity | CNAME
|
|
215
|
+
def value(self, v):
|
|
216
|
+
# Normalize to primitives/strings like other atoms; entities are already normalized by entity()
|
|
217
|
+
return _atom(v)
|
|
112
218
|
# ============ Aliases / Sync ============
|
|
113
219
|
def alias(self, *args):
|
|
114
220
|
private = False
|
|
@@ -193,8 +299,11 @@ class HasslTransformer(Transformer):
|
|
|
193
299
|
return {"type": "attr_assign", "entity": entity, "attr": attr, "value": value}
|
|
194
300
|
|
|
195
301
|
def waitact(self, cond, dur, action):
|
|
196
|
-
return {"type": "wait", "condition": cond, "for": dur, "then": action}
|
|
302
|
+
return {"type": "wait", "condition": cond, "for": _atom(dur), "then": action}
|
|
197
303
|
|
|
304
|
+
def dur_arg(self, val):
|
|
305
|
+
return _atom(val)
|
|
306
|
+
|
|
198
307
|
def rulectrl(self, *parts):
|
|
199
308
|
def s(x): return str(x) if isinstance(x, Token) else x
|
|
200
309
|
vals = [s(p) for p in parts]
|
|
@@ -296,33 +405,6 @@ class HasslTransformer(Transformer):
|
|
|
296
405
|
def time_spec(self, *children): return children[0] if children else None
|
|
297
406
|
def rule_clause(self, item): return item
|
|
298
407
|
|
|
299
|
-
def sched_holiday_only(self, *args):
|
|
300
|
-
"""
|
|
301
|
-
Handles: on holidays <CNAME> HH:MM-HH:MM ;
|
|
302
|
-
Args arrive as ( 'on', 'holidays', <CNAME token>, time_range_tuple, ';' )
|
|
303
|
-
"""
|
|
304
|
-
from lark import Token
|
|
305
|
-
|
|
306
|
-
ident = None
|
|
307
|
-
start = None
|
|
308
|
-
end = None
|
|
309
|
-
|
|
310
|
-
for a in args:
|
|
311
|
-
if isinstance(a, Token) and a.type == "CNAME":
|
|
312
|
-
ident = str(a)
|
|
313
|
-
elif isinstance(a, tuple) and a and a[0] == "time":
|
|
314
|
-
# ("time", "HH:MM", "HH:MM")
|
|
315
|
-
start, end = a[1], a[2]
|
|
316
|
-
|
|
317
|
-
return nodes.ScheduleWindow(
|
|
318
|
-
start=str(start) if start is not None else "00:00",
|
|
319
|
-
end=str(end) if end is not None else "00:00",
|
|
320
|
-
day_selector="daily",
|
|
321
|
-
period=None,
|
|
322
|
-
holiday_ref=str(ident) if ident is not None else "",
|
|
323
|
-
holiday_mode="only",
|
|
324
|
-
)
|
|
325
|
-
|
|
326
408
|
# -------- New windows & periods --------
|
|
327
409
|
def schedule_window_clause(self, *parts):
|
|
328
410
|
# Reset sticky day for each clause
|
hassl/semantics/analyzer.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
1
|
+
from dataclasses import dataclass, field, is_dataclass, asdict
|
|
2
2
|
from typing import Dict, List, Any, Optional, Tuple
|
|
3
|
+
import copy
|
|
3
4
|
from ..ast.nodes import (
|
|
4
5
|
Program, Alias, Sync, Rule, Schedule,
|
|
5
6
|
HolidaySet, ScheduleWindow, PeriodSelector,
|
|
7
|
+
TemplateDecl, UseTemplate,
|
|
6
8
|
)
|
|
7
9
|
from .domains import DOMAIN_PROPS, domain_of
|
|
8
10
|
|
|
@@ -60,6 +62,20 @@ class IRProgram:
|
|
|
60
62
|
"schedules_windows": self.schedules_windows or {}, # NEW
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
def _resolve_module_id(raw_mod: str) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Map a raw import like 'hall.aliases' to the actual package id present in GLOBAL_EXPORTS,
|
|
68
|
+
e.g. 'home.hall.aliases'. If multiple candidates match, keep raw (fail gently).
|
|
69
|
+
"""
|
|
70
|
+
if 'GLOBAL_EXPORTS' not in globals() or not raw_mod:
|
|
71
|
+
return raw_mod
|
|
72
|
+
# exact hit?
|
|
73
|
+
if any(pkg == raw_mod for (pkg, _k, _n) in globals()['GLOBAL_EXPORTS']):
|
|
74
|
+
return raw_mod
|
|
75
|
+
# suffix match (common when files declare 'home.hall.aliases' but source wrote 'hall.aliases')
|
|
76
|
+
candidates = {pkg for (pkg, _k, _n) in globals()['GLOBAL_EXPORTS'] if pkg.endswith("." + raw_mod)}
|
|
77
|
+
return next(iter(candidates)) if len(candidates) == 1 else raw_mod
|
|
78
|
+
|
|
63
79
|
def _resolve_alias(e: str, amap: Dict[str,str]) -> str:
|
|
64
80
|
if "." not in e and e in amap: return amap[e]
|
|
65
81
|
return e
|
|
@@ -114,8 +130,148 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
114
130
|
local_schedule_windows: Dict[str, List[ScheduleWindow]] = {}
|
|
115
131
|
local_holidays: Dict[str, HolidaySet] = {}
|
|
116
132
|
|
|
117
|
-
#
|
|
118
|
-
|
|
133
|
+
# 0) Preprocess: collect templates and expand 'use template' into concrete nodes
|
|
134
|
+
templates_by_kind: Dict[str, Dict[str, TemplateDecl]] = {"rule": {}, "sync": {}, "schedule": {}}
|
|
135
|
+
|
|
136
|
+
# Collect modules this program imports (treat bare "import X" as glob, same as you already do)
|
|
137
|
+
imported_modules = set()
|
|
138
|
+
for imp in getattr(prog, "imports", []) or []:
|
|
139
|
+
if not isinstance(imp, dict) or imp.get("type") != "import":
|
|
140
|
+
continue
|
|
141
|
+
raw_mod = imp.get("module", "")
|
|
142
|
+
mod = _resolve_module_id(raw_mod)
|
|
143
|
+
|
|
144
|
+
if not mod:
|
|
145
|
+
continue
|
|
146
|
+
# normalize kind the same way you do later
|
|
147
|
+
kind = imp.get("kind")
|
|
148
|
+
if kind not in ("glob", "list", "alias"):
|
|
149
|
+
if imp.get("items"): kind = "list"
|
|
150
|
+
elif imp.get("as"): kind = "alias"
|
|
151
|
+
else: kind = "glob"
|
|
152
|
+
|
|
153
|
+
if kind in ("glob", "list", "alias"):
|
|
154
|
+
imported_modules.add(mod)
|
|
155
|
+
|
|
156
|
+
# Helper: build arg map (params -> values) using defaults
|
|
157
|
+
def _bind_args(t: TemplateDecl, call_args: List[Any]) -> Dict[str, Any]:
|
|
158
|
+
params = list(t.params or [])
|
|
159
|
+
# normalize params: [{"name":..., "default":...}, ...]
|
|
160
|
+
pnames = [p.get("name") for p in params]
|
|
161
|
+
defaults = {p.get("name"): p.get("default") for p in params}
|
|
162
|
+
bound: Dict[str, Any] = dict(defaults)
|
|
163
|
+
# split named vs positional args from transformer
|
|
164
|
+
pos: List[Any] = []
|
|
165
|
+
named: Dict[str, Any] = {}
|
|
166
|
+
for a in call_args or []:
|
|
167
|
+
if isinstance(a, dict) and "name" in a:
|
|
168
|
+
named[str(a["name"])] = a.get("value")
|
|
169
|
+
else:
|
|
170
|
+
pos.append(a)
|
|
171
|
+
# apply positional
|
|
172
|
+
for i, v in enumerate(pos):
|
|
173
|
+
if i < len(pnames):
|
|
174
|
+
bound[pnames[i]] = v
|
|
175
|
+
# apply named (wins over positional/default)
|
|
176
|
+
for k, v in named.items():
|
|
177
|
+
if k in pnames:
|
|
178
|
+
bound[k] = v
|
|
179
|
+
return bound
|
|
180
|
+
|
|
181
|
+
# Deep substitute param identifiers appearing as bare strings in nested dict/list trees
|
|
182
|
+
def _deep_subst(obj: Any, subst: Dict[str, Any]) -> Any:
|
|
183
|
+
# strings: replace only if exactly matches a parameter name
|
|
184
|
+
if isinstance(obj, str):
|
|
185
|
+
return str(subst.get(obj, obj))
|
|
186
|
+
# dicts/lists: walk recursively
|
|
187
|
+
if isinstance(obj, dict):
|
|
188
|
+
return {k: _deep_subst(v, subst) for k, v in obj.items()}
|
|
189
|
+
if isinstance(obj, list):
|
|
190
|
+
return [_deep_subst(x, subst) for x in obj]
|
|
191
|
+
# leave everything else as-is (numbers, bools, None)
|
|
192
|
+
return obj
|
|
193
|
+
|
|
194
|
+
# Expand a single UseTemplate into a concrete node (Rule/Sync/Schedule)
|
|
195
|
+
def _instantiate(use: UseTemplate) -> Optional[Any]:
|
|
196
|
+
# Find matching template by name across kinds (prefer rule->sync->schedule)
|
|
197
|
+
t = None
|
|
198
|
+
for kind in ("rule", "sync", "schedule"):
|
|
199
|
+
t = templates_by_kind.get(kind, {}).get(use.name)
|
|
200
|
+
if t:
|
|
201
|
+
break
|
|
202
|
+
if not t:
|
|
203
|
+
return None
|
|
204
|
+
argmap = _bind_args(t, list(getattr(use, "args", []) or []))
|
|
205
|
+
|
|
206
|
+
if t.body is None:
|
|
207
|
+
# Provide minimal empty bodies so deep_subst & constructors don’t break
|
|
208
|
+
if t.kind == "rule":
|
|
209
|
+
t.body = Rule(name="", clauses=[])
|
|
210
|
+
elif t.kind == "sync":
|
|
211
|
+
t.body = Sync(kind="onoff", members=[], name="", invert=[])
|
|
212
|
+
elif t.kind == "schedule":
|
|
213
|
+
t.body = Schedule(name="", clauses=[], windows=[], private=False)
|
|
214
|
+
original = copy.deepcopy(t.body)
|
|
215
|
+
# Plainify dataclasses/objects so deep_subst can walk them
|
|
216
|
+
if is_dataclass(original):
|
|
217
|
+
plain = asdict(original)
|
|
218
|
+
elif hasattr(original, "__dict__"):
|
|
219
|
+
# shallow mapping of fields; they will typically be lists/dicts we can walk
|
|
220
|
+
plain = copy.deepcopy(vars(original))
|
|
221
|
+
else:
|
|
222
|
+
plain = original
|
|
223
|
+
subbed = _deep_subst(plain, argmap)
|
|
224
|
+
|
|
225
|
+
# Rename resulting node if caller provided "as <name>"
|
|
226
|
+
# Rename resulting node if caller provided "as <name>" or passed name= param
|
|
227
|
+
new_name = getattr(use, "as_name", None) or str(argmap.get("name") or t.name)
|
|
228
|
+
|
|
229
|
+
# Construct concrete AST node of same kind
|
|
230
|
+
if isinstance(t.body, Rule) or (getattr(t.body, "__class__", None).__name__ == "Rule"):
|
|
231
|
+
# subbed is a dict after plainify/subst
|
|
232
|
+
return Rule(name=new_name, clauses=subbed.get("clauses", getattr(original, "clauses", [])))
|
|
233
|
+
if isinstance(t.body, Sync) or (getattr(t.body, "__class__", None).__name__ == "Sync"):
|
|
234
|
+
return Sync(kind=subbed.get("kind", getattr(original, "kind", "onoff")),
|
|
235
|
+
members=subbed.get("members", getattr(original, "members", [])),
|
|
236
|
+
name=new_name,
|
|
237
|
+
invert=subbed.get("invert", getattr(original, "invert", [])))
|
|
238
|
+
if isinstance(t.body, Schedule) or (getattr(t.body, "__class__", None).__name__ == "Schedule"):
|
|
239
|
+
return Schedule(name=new_name,
|
|
240
|
+
clauses=subbed.get("clauses", getattr(original, "clauses", [])),
|
|
241
|
+
windows=subbed.get("windows", getattr(original, "windows", [])),
|
|
242
|
+
private=subbed.get("private", getattr(original, "private", False)))
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
# Scan once to collect templates
|
|
246
|
+
for s in getattr(prog, "statements", []) or []:
|
|
247
|
+
if isinstance(s, TemplateDecl):
|
|
248
|
+
kind = (s.kind or "rule").lower()
|
|
249
|
+
if kind in templates_by_kind:
|
|
250
|
+
templates_by_kind[kind][s.name] = s
|
|
251
|
+
|
|
252
|
+
if 'GLOBAL_EXPORTS' in globals():
|
|
253
|
+
for (pkg, kind, name), node in globals()['GLOBAL_EXPORTS'].items():
|
|
254
|
+
if kind != "template":
|
|
255
|
+
continue
|
|
256
|
+
# bring templates from any imported module
|
|
257
|
+
if pkg in imported_modules and isinstance(node, TemplateDecl):
|
|
258
|
+
tkind = (node.kind or "rule").lower()
|
|
259
|
+
if tkind in templates_by_kind and name not in templates_by_kind[tkind]:
|
|
260
|
+
templates_by_kind[tkind][name] = node
|
|
261
|
+
|
|
262
|
+
# Build a new statement list with uses expanded
|
|
263
|
+
expanded_statements: List[Any] = []
|
|
264
|
+
for s in getattr(prog, "statements", []) or []:
|
|
265
|
+
if isinstance(s, UseTemplate):
|
|
266
|
+
inst = _instantiate(s)
|
|
267
|
+
if inst is not None:
|
|
268
|
+
expanded_statements.append(inst)
|
|
269
|
+
# do not append the UseTemplate node itself
|
|
270
|
+
else:
|
|
271
|
+
expanded_statements.append(s)
|
|
272
|
+
|
|
273
|
+
# 1) Collect local declarations (aliases & schedules) from expanded statements
|
|
274
|
+
for s in expanded_statements:
|
|
119
275
|
if isinstance(s, Alias):
|
|
120
276
|
local_aliases[s.name] = s.entity
|
|
121
277
|
is_private = getattr(s, "private", False)
|
|
@@ -173,7 +329,9 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
173
329
|
if not isinstance(imp, dict) or imp.get("type") != "import":
|
|
174
330
|
# transformer may also append sentinels to statements; ignore here
|
|
175
331
|
continue
|
|
176
|
-
|
|
332
|
+
raw_mod = imp.get("module", "")
|
|
333
|
+
mod = _resolve_module_id(raw_mod)
|
|
334
|
+
|
|
177
335
|
kind = imp.get("kind")
|
|
178
336
|
# Be generous: if transformer emitted "none" or omitted kind, infer it.
|
|
179
337
|
if kind not in ("glob", "list", "alias"):
|
|
@@ -224,7 +382,7 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
224
382
|
|
|
225
383
|
# --- Syncs ---
|
|
226
384
|
syncs: List[IRSync] = []
|
|
227
|
-
for s in
|
|
385
|
+
for s in expanded_statements:
|
|
228
386
|
if isinstance(s, Sync):
|
|
229
387
|
mem = [_resolve_alias(m,amap) for m in s.members]
|
|
230
388
|
inv = [_resolve_alias(m,amap) for m in s.invert]
|
|
@@ -374,8 +532,22 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
374
532
|
if ent:
|
|
375
533
|
return ent
|
|
376
534
|
return obj
|
|
535
|
+
|
|
536
|
+
# Resolve only qualified aliases (ns.alias) and leave unqualified aliases intact.
|
|
537
|
+
# This keeps existing IR expectations while making qualified references usable.
|
|
538
|
+
def _walk_qualified_only(obj: Any) -> Any:
|
|
539
|
+
if isinstance(obj, dict):
|
|
540
|
+
return {k: _walk_qualified_only(v) for k, v in obj.items()}
|
|
541
|
+
if isinstance(obj, list):
|
|
542
|
+
return [_walk_qualified_only(x) for x in obj]
|
|
543
|
+
if isinstance(obj, str):
|
|
544
|
+
if "." in obj:
|
|
545
|
+
ent = _resolve_qualified_alias(obj)
|
|
546
|
+
if ent:
|
|
547
|
+
return ent
|
|
548
|
+
return obj
|
|
377
549
|
|
|
378
|
-
for s in
|
|
550
|
+
for s in expanded_statements:
|
|
379
551
|
if isinstance(s, Rule):
|
|
380
552
|
clauses: List[dict] = []
|
|
381
553
|
schedule_uses: List[str] = []
|
|
@@ -387,8 +559,9 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
387
559
|
# IfClause-like items have .condition/.actions
|
|
388
560
|
if hasattr(c, "condition") and hasattr(c, "actions"):
|
|
389
561
|
# Keep alias identifiers intact for tests & codegen (resolve later)
|
|
390
|
-
|
|
391
|
-
|
|
562
|
+
# But resolve qualified aliases (ns.alias) so codegen gets real entities
|
|
563
|
+
cond = _walk_qualified_only(c.condition)
|
|
564
|
+
acts = _walk_qualified_only(c.actions)
|
|
392
565
|
clauses.append({"condition": cond, "actions": acts})
|
|
393
566
|
elif isinstance(c, dict) and c.get("type") == "schedule_use":
|
|
394
567
|
# {"type":"schedule_use","names":[...]}
|
|
@@ -407,6 +580,10 @@ def analyze(prog: Program) -> IRProgram:
|
|
|
407
580
|
for sc in c.get("clauses") or []:
|
|
408
581
|
if isinstance(sc, dict):
|
|
409
582
|
schedules_inline.append(sc)
|
|
583
|
+
elif isinstance(c, dict) and "condition" in c and "actions" in c:
|
|
584
|
+
cond = _walk_alias_with_qualified(c["condition"])
|
|
585
|
+
acts = _walk_alias_with_qualified(c["actions"])
|
|
586
|
+
clauses.append({"condition": cond, "actions": acts})
|
|
410
587
|
else:
|
|
411
588
|
# ignore unknown fragments
|
|
412
589
|
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hassl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: HASSL: Home Assistant Simple Scripting Language
|
|
5
5
|
Home-page: https://github.com/adanowitz/hassl
|
|
6
6
|
Author: adanowitz
|
|
@@ -17,7 +17,7 @@ Dynamic: license-file
|
|
|
17
17
|
|
|
18
18
|
> **Home Assistant Simple Scripting Language**
|
|
19
19
|
|
|
20
|
-

|
|
21
21
|
|
|
22
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/).
|
|
23
23
|
|
|
@@ -30,7 +30,7 @@ It compiles lightweight `.hassl` scripts into fully functional YAML packages tha
|
|
|
30
30
|
- **Readable DSL** → write logic like natural language (`if motion && lux < 50 then light = on`)
|
|
31
31
|
- **Sync devices** → keep switches, dimmers, and fans perfectly in sync
|
|
32
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.
|
|
33
|
+
- **Weekday/weekend/holiday schedules** → full support for Home Assistant’s **Workday integration** (v0.4.0)
|
|
34
34
|
- **Loop-safe** → context ID tracking prevents feedback loops
|
|
35
35
|
- **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
|
|
36
36
|
- **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
|
|
@@ -148,7 +148,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
|
|
|
148
148
|
| `scripts_<pkg>.yaml` | Writer scripts with context stamping |
|
|
149
149
|
| `sync_<pkg>_*.yaml` | Sync automations for each property |
|
|
150
150
|
| `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
|
|
151
|
-
| `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.
|
|
151
|
+
| `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.4.0) |
|
|
152
152
|
|
|
153
153
|
---
|
|
154
154
|
|
|
@@ -181,7 +181,7 @@ All schedules are restart-safe:
|
|
|
181
181
|
|
|
182
182
|
---
|
|
183
183
|
|
|
184
|
-
## 🗓️ Holiday & Workday Integration (v0.
|
|
184
|
+
## 🗓️ Holiday & Workday Integration (v0.4.0)
|
|
185
185
|
|
|
186
186
|
HASSL now supports `holidays <id>:` schedules tied to Home Assistant’s **Workday** integration.
|
|
187
187
|
|
|
@@ -238,7 +238,7 @@ Once created, HASSL automatically references them in generated automations.
|
|
|
238
238
|
|
|
239
239
|
## ⚗️ Experimental: Date & Month Range Schedules
|
|
240
240
|
|
|
241
|
-
HASSL v0.
|
|
241
|
+
HASSL v0.4.0 includes early support for:
|
|
242
242
|
|
|
243
243
|
```hassl
|
|
244
244
|
on months Jun–Aug 07:00–22:00;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
hassl/__init__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
|
|
2
|
+
hassl/cli.py,sha256=8y15glAYjHsJChaHefwqvoC5sz-l-pt9E9mWtzrt-Ik,9890
|
|
3
|
+
hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
hassl/ast/nodes.py,sha256=Z6Ac5RrDEv6KNAAxdKQ3wrnnoHqbgP6-SDvda9Z8CNk,3254
|
|
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=wruN26-wo64RBb94SxyEhG_4NIc6zBFcvOhGpuO_CIA,43289
|
|
9
|
+
hassl/codegen/rules_min.py,sha256=3hiL0dUvlU9uIf1kc_BYcyTsaWGUBS787WkaIh1vZwk,28909
|
|
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=aBlZ609bZ-Wnrs5EsFukDqAIo5xLNQ6p2ruS5qmxtI8,6978
|
|
13
|
+
hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
|
|
14
|
+
hassl/parser/transform.py,sha256=xVz1fKjfxHbiTeUFuZJbVwctGWwd4cdvAPhfKgslwbs,25328
|
|
15
|
+
hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
hassl/semantics/analyzer.py,sha256=layYd1x_UajNPZN9Up_fp2dfApnBzCyeoG351nutpjU,28641
|
|
17
|
+
hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
|
|
18
|
+
hassl-0.4.0.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
|
|
19
|
+
hassl-0.4.0.dist-info/METADATA,sha256=Noo52fAx3gT0U5s0hrNMeia8BLnLgXnXsuSkhKpyZZE,9006
|
|
20
|
+
hassl-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
+
hassl-0.4.0.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
|
|
22
|
+
hassl-0.4.0.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
|
|
23
|
+
hassl-0.4.0.dist-info/RECORD,,
|
hassl-0.3.2.dist-info/RECORD
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
hassl/__init__.py,sha256=vNiWJ14r_cw5t_7UDqDQIVZvladKFGyHH2avsLpN7Vg,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=7c0hQaGYQ80gNynMghyETFwqYGR3Xp1_RlqWAs9_qw4,43124
|
|
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=73c1wmHaTydRDIcVjIEsQLmfFcylIOfQyqkLvNhFryM,5729
|
|
13
|
-
hassl/parser/loader.py,sha256=xwhdoMkmXskanY8IhqIOfkcOkkn33goDnsbjzS66FL8,249
|
|
14
|
-
hassl/parser/transform.py,sha256=Jg4y7bqrc14aA73SuvrfjyZlc6qs0LqqbNis5vzFqws,21721
|
|
15
|
-
hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
-
hassl/semantics/analyzer.py,sha256=m6AvDVLJPzbRKMXJMcMbRjD1Ppl67NI7WEVN1DLudRo,20220
|
|
17
|
-
hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
|
|
18
|
-
hassl-0.3.2.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
|
|
19
|
-
hassl-0.3.2.dist-info/METADATA,sha256=K7Da3-A3a0Ih17mfj6wNLy2VYnmRvx21AH4bc9mzqK8,9006
|
|
20
|
-
hassl-0.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
21
|
-
hassl-0.3.2.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
|
|
22
|
-
hassl-0.3.2.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
|
|
23
|
-
hassl-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|