hassl 0.3.1__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 +23 -4
- hassl/codegen/rules_min.py +13 -19
- hassl/parser/hassl.lark +68 -30
- hassl/parser/transform.py +252 -345
- hassl/semantics/analyzer.py +235 -12
- {hassl-0.3.1.dist-info → hassl-0.4.0.dist-info}/METADATA +6 -6
- hassl-0.4.0.dist-info/RECORD +23 -0
- {hassl-0.3.1.dist-info → hassl-0.4.0.dist-info}/WHEEL +1 -1
- hassl-0.3.1.dist-info/RECORD +0 -23
- {hassl-0.3.1.dist-info → hassl-0.4.0.dist-info}/entry_points.txt +0 -0
- {hassl-0.3.1.dist-info → hassl-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {hassl-0.3.1.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
|
@@ -215,7 +215,20 @@ def _holiday_condition(mode: Optional[str], hol_id: Optional[str]):
|
|
|
215
215
|
# True when today is a holiday for 'only', false when 'except'
|
|
216
216
|
eid = f"binary_sensor.hassl_holiday_{hol_id}"
|
|
217
217
|
return {"condition": "state", "entity_id": eid, "state": "on" if mode == "only" else "off"}
|
|
218
|
-
|
|
218
|
+
|
|
219
|
+
def _norm_hmode(raw: Optional[str]) -> Optional[str]:
|
|
220
|
+
"""Coerce analyzer-provided holiday_mode variants to {'only','except',None}."""
|
|
221
|
+
if not raw:
|
|
222
|
+
return None
|
|
223
|
+
v = str(raw).strip().lower().replace("_", " ").replace("-", " ")
|
|
224
|
+
# Accept a bunch of user/analyzer phrasings
|
|
225
|
+
if any(k in v for k in ("except", "exclude", "unless", "not")):
|
|
226
|
+
return "except"
|
|
227
|
+
if any(k in v for k in ("only", "holiday only", "holidays only")):
|
|
228
|
+
return "only"
|
|
229
|
+
# Unknown → leave as-is to avoid surprising behavior
|
|
230
|
+
return raw
|
|
231
|
+
|
|
219
232
|
def _trigger_for(ts: Dict[str, Any]) -> Dict[str, Any]:
|
|
220
233
|
"""
|
|
221
234
|
Build a HA trigger for a time-spec dict:
|
|
@@ -787,8 +800,10 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
787
800
|
|
|
788
801
|
ds = w.get("day_selector")
|
|
789
802
|
href = w.get("holiday_ref")
|
|
790
|
-
hmode = w.get("holiday_mode")
|
|
803
|
+
hmode = _norm_hmode(w.get("holiday_mode"))
|
|
791
804
|
period = w.get("period")
|
|
805
|
+
if href and hmode is None and ds in ("weekdays", "weekends"):
|
|
806
|
+
hmode = "except"
|
|
792
807
|
|
|
793
808
|
# Coerce time specs to dicts compatible with _trigger_for/_window_condition_from_specs
|
|
794
809
|
raw_start = w.get("start")
|
|
@@ -797,9 +812,13 @@ def emit_package(ir: IRProgram, outdir: str):
|
|
|
797
812
|
if isinstance(ts, dict):
|
|
798
813
|
return ts
|
|
799
814
|
if isinstance(ts, str):
|
|
815
|
+
v = ts.strip()
|
|
800
816
|
# accept "HH:MM" or "HH:MM:SS"
|
|
801
|
-
|
|
802
|
-
|
|
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"}
|
|
803
822
|
return {"kind":"clock","value":"00:00"}
|
|
804
823
|
start_ts = _coerce(raw_start)
|
|
805
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,37 +86,24 @@ 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
|
|
|
108
92
|
// Clauses usable both in declarations and inline
|
|
109
93
|
// Keep legacy form AND add new 'on …' forms
|
|
110
94
|
schedule_clause: schedule_legacy_clause
|
|
111
|
-
|
|
|
95
|
+
| schedule_window_clause
|
|
96
|
+
| sched_holiday_only
|
|
112
97
|
|
|
113
98
|
// Legacy (unchanged)
|
|
114
99
|
schedule_legacy_clause: schedule_op FROM time_spec schedule_end? ";"
|
|
115
100
|
|
|
116
101
|
// NEW schedule window syntax:
|
|
117
|
-
// [during <period>] on (
|
|
102
|
+
// [during <period>] on (weekdaysNo, |weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
|
|
118
103
|
// on holidays <id> HH:MM-HH:MM ;
|
|
119
|
-
|
|
120
|
-
| "on" "holidays" CNAME time_range ";"
|
|
104
|
+
schedule_window_clause: period? "on" day_selector time_range holiday_mod? ";"
|
|
121
105
|
|
|
106
|
+
sched_holiday_only: "on" "holidays" CNAME time_range ";"
|
|
122
107
|
// Periods
|
|
123
108
|
period: "during" "months" month_range
|
|
124
109
|
| "during" "dates" mmdd_range
|
|
@@ -131,7 +116,7 @@ MMDD: /\d{2}-\d{2}/
|
|
|
131
116
|
ymd_range: YMD ".." YMD
|
|
132
117
|
YMD: /\d{4}-\d{2}-\d{2}/
|
|
133
118
|
|
|
134
|
-
day_selector:
|
|
119
|
+
day_selector: WEEKDAYS | WEEKENDS | DAILY
|
|
135
120
|
time_range: TIME_HHMM "-" TIME_HHMM
|
|
136
121
|
holiday_mod: "except" "holidays" CNAME
|
|
137
122
|
|
|
@@ -140,7 +125,7 @@ schedule_end: TO time_spec -> schedule_to
|
|
|
140
125
|
| UNTIL time_spec -> schedule_until
|
|
141
126
|
|
|
142
127
|
// Rule-level usage
|
|
143
|
-
rule_schedule_use: SCHEDULE USE name_list ";"
|
|
128
|
+
rule_schedule_use: SCHEDULE USE name_list ";"?
|
|
144
129
|
// allow qualified (pkg.symbol) via existing 'entity' rule
|
|
145
130
|
name_list: name ("," name)*
|
|
146
131
|
name: CNAME | entity
|
|
@@ -162,12 +147,41 @@ member: entity | CNAME
|
|
|
162
147
|
|
|
163
148
|
entity: CNAME ("." CNAME)+
|
|
164
149
|
|
|
150
|
+
|
|
165
151
|
// ---- Lexer ----
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
|
|
168
174
|
%import common.ESCAPED_STRING -> STRING
|
|
169
175
|
%import common.INT
|
|
170
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
|
+
|
|
171
185
|
LINE_COMMENT: /\/\/[^\n]*/
|
|
172
186
|
%ignore LINE_COMMENT
|
|
173
187
|
%ignore WS
|
|
@@ -188,7 +202,7 @@ import_tail: ".*"
|
|
|
188
202
|
import_list: import_item ("," import_item)*
|
|
189
203
|
import_item: CNAME ("as" CNAME)?
|
|
190
204
|
|
|
191
|
-
// ---- Holidays declaration
|
|
205
|
+
// ---- Holidays declaration ----
|
|
192
206
|
// Example:
|
|
193
207
|
// holidays us_ca:
|
|
194
208
|
// country="US", province="CA", add=["2025-11-28"], remove=["2025-12-26"]
|
|
@@ -203,4 +217,28 @@ daylist: DAY ("," DAY)*
|
|
|
203
217
|
excludelist: ("sat"|"sun"|"holiday") ("," ("sat"|"sun"|"holiday"))*
|
|
204
218
|
DAY.2: "mon"|"tue"|"wed"|"thu"|"fri"|"sat"|"sun"
|
|
205
219
|
DATESTR: /"\d{4}-\d{2}-\d{2}"/
|
|
206
|
-
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
|