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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.1"
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
- # programs: List[tuple[Path, Program, str]]
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
- for _, prog, importer_pkg in list(programs):
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
- # resolve relative notation against the importing package
123
+
113
124
  abs_mod = _normalize_module(importer_pkg, raw_mod)
114
- if abs_mod in known_pkgs:
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
- if not module_root:
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
- printable = {f"{k[0]}::{k[1]}::{k[2]}": ("Alias" if isinstance(v, Alias) else "Schedule") for k, v in GLOBAL_EXPORTS.items()}
201
- json.dump(printable, fp, indent=2)
202
- print(f"[hasslc] Global exports index written to {out_root / 'DEBUG_exports.json'}")
203
-
204
- if __name__ == "__main__":
205
- main()
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
- v = ts if len(ts) in (5,8) else "00:00"
802
- return {"kind":"clock","value": v[:5] if len(v)==5 else v[:8]}
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)
@@ -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 schedule sensor ids
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
- f"was declared in this package '{pkg}'.\n\n"
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
- # Try light.turn_on data first; fallback to generic service data
597
- if eid.startswith("light."):
598
- act_list.append({"service": "light.turn_on", "target": {"entity_id": eid}, "data": {attr: val}})
599
- else:
600
- act_list.append({"service": "homeassistant.turn_on", "target": {"entity_id": eid}, "data": {attr: val}})
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
- | schedule_new_clause
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 (weekdays|weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
102
+ // [during <period>] on (weekdaysNo, |weekends|daily) HH:MM-HH:MM [except holidays <id>] ;
118
103
  // on holidays <id> HH:MM-HH:MM ;
119
- schedule_new_clause: period? "on" day_selector time_range holiday_mod? ";"
120
- | "on" "holidays" CNAME time_range ";"
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: "weekdays" | "weekends" | "daily"
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
- %import common.CNAME
167
- %import common.WS
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 (NEW) ----
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