hassl 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (36) hide show
  1. {hassl-0.3.1/hassl.egg-info → hassl-0.4.0}/PKG-INFO +6 -6
  2. {hassl-0.3.1 → hassl-0.4.0}/README.md +5 -5
  3. hassl-0.4.0/hassl/__init__.py +1 -0
  4. {hassl-0.3.1 → hassl-0.4.0}/hassl/ast/nodes.py +16 -1
  5. hassl-0.4.0/hassl/cli.py +238 -0
  6. {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/package.py +23 -4
  7. {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/rules_min.py +13 -19
  8. {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/hassl.lark +68 -30
  9. {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/transform.py +252 -345
  10. {hassl-0.3.1 → hassl-0.4.0}/hassl/semantics/analyzer.py +235 -12
  11. {hassl-0.3.1 → hassl-0.4.0/hassl.egg-info}/PKG-INFO +6 -6
  12. {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/SOURCES.txt +2 -1
  13. {hassl-0.3.1 → hassl-0.4.0}/pyproject.toml +1 -1
  14. {hassl-0.3.1 → hassl-0.4.0}/setup.cfg +1 -1
  15. {hassl-0.3.1 → hassl-0.4.0}/tests/test_imports_and_schedules.py +65 -3
  16. {hassl-0.3.1 → hassl-0.4.0}/tests/test_schedule_windows_codegen.py +1 -3
  17. hassl-0.4.0/tests/test_templates.py +56 -0
  18. hassl-0.3.1/hassl/__init__.py +0 -1
  19. hassl-0.3.1/hassl/cli.py +0 -372
  20. {hassl-0.3.1 → hassl-0.4.0}/LICENSE +0 -0
  21. {hassl-0.3.1 → hassl-0.4.0}/MANIFEST.in +0 -0
  22. {hassl-0.3.1 → hassl-0.4.0}/hassl/ast/__init__.py +0 -0
  23. {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/__init__.py +0 -0
  24. {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/generate.py +0 -0
  25. {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/init.py +0 -0
  26. {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/yaml_emit.py +0 -0
  27. {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/__init__.py +0 -0
  28. {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/loader.py +0 -0
  29. {hassl-0.3.1 → hassl-0.4.0}/hassl/semantics/__init__.py +0 -0
  30. {hassl-0.3.1 → hassl-0.4.0}/hassl/semantics/domains.py +0 -0
  31. {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/dependency_links.txt +0 -0
  32. {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/entry_points.txt +0 -0
  33. {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/requires.txt +0 -0
  34. {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/top_level.txt +0 -0
  35. {hassl-0.3.1 → hassl-0.4.0}/tests/test_codegen_sync_basic.py +0 -0
  36. {hassl-0.3.1 → hassl-0.4.0}/tests/test_golden_ir_sync_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hassl
3
- Version: 0.3.1
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
- ![Version](https://img.shields.io/badge/version-v0.3.1-blue)
20
+ ![Version](https://img.shields.io/badge/version-v0.4.0-blue)
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.3.1)
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.3.1) |
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.3.1)
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.3.1 includes early support for:
241
+ HASSL v0.4.0 includes early support for:
242
242
 
243
243
  ```hassl
244
244
  on months Jun–Aug 07:00–22:00;
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Home Assistant Simple Scripting Language**
4
4
 
5
- ![Version](https://img.shields.io/badge/version-v0.3.1-blue)
5
+ ![Version](https://img.shields.io/badge/version-v0.4.0-blue)
6
6
 
7
7
  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/).
8
8
 
@@ -15,7 +15,7 @@ It compiles lightweight `.hassl` scripts into fully functional YAML packages tha
15
15
  - **Readable DSL** → write logic like natural language (`if motion && lux < 50 then light = on`)
16
16
  - **Sync devices** → keep switches, dimmers, and fans perfectly in sync
17
17
  - **Schedules** → declare time-based gates (`enable from 08:00 until 19:00`)
18
- - **Weekday/weekend/holiday schedules** → full support for Home Assistant’s **Workday integration** (v0.3.1)
18
+ - **Weekday/weekend/holiday schedules** → full support for Home Assistant’s **Workday integration** (v0.4.0)
19
19
  - **Loop-safe** → context ID tracking prevents feedback loops
20
20
  - **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
21
21
  - **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
@@ -133,7 +133,7 @@ Each `.hassl` file compiles into an isolated package — no naming collisions, n
133
133
  | `scripts_<pkg>.yaml` | Writer scripts with context stamping |
134
134
  | `sync_<pkg>_*.yaml` | Sync automations for each property |
135
135
  | `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
136
- | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.3.1) |
136
+ | `schedules_<pkg>.yaml` | Time/sun-based schedule sensors (v0.4.0) |
137
137
 
138
138
  ---
139
139
 
@@ -166,7 +166,7 @@ All schedules are restart-safe:
166
166
 
167
167
  ---
168
168
 
169
- ## 🗓️ Holiday & Workday Integration (v0.3.1)
169
+ ## 🗓️ Holiday & Workday Integration (v0.4.0)
170
170
 
171
171
  HASSL now supports `holidays <id>:` schedules tied to Home Assistant’s **Workday** integration.
172
172
 
@@ -223,7 +223,7 @@ Once created, HASSL automatically references them in generated automations.
223
223
 
224
224
  ## ⚗️ Experimental: Date & Month Range Schedules
225
225
 
226
- HASSL v0.3.1 includes early support for:
226
+ HASSL v0.4.0 includes early support for:
227
227
 
228
228
  ```hassl
229
229
  on months Jun–Aug 07:00–22:00;
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -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 {
@@ -0,0 +1,238 @@
1
+ import argparse
2
+ import os, json, glob
3
+ from pathlib import Path
4
+ from typing import Dict, Tuple, List
5
+ from .parser.loader import load_grammar_text
6
+ from .parser.transform import HasslTransformer
7
+ from .ast.nodes import Program, Alias, Schedule, TemplateDecl
8
+ from lark import Lark
9
+ from .semantics import analyzer as sem_analyzer
10
+ from .semantics.analyzer import analyze
11
+ from .codegen.package import emit_package
12
+ from .codegen import generate as codegen_generate
13
+
14
+ def parse_hassl(text: str) -> Program:
15
+ grammar = load_grammar_text()
16
+ parser = Lark(grammar, start="start", parser="lalr", maybe_placeholders=False)
17
+ tree = parser.parse(text)
18
+ program = HasslTransformer().transform(tree)
19
+ return program
20
+
21
+
22
+ def _normalize_module(importing_pkg: str, mod: str) -> str:
23
+ """
24
+ Resolve Python-like relative module notation to an absolute dotted id.
25
+ Examples (importing_pkg='home.addie.automations'):
26
+ '.shared' -> 'home.addie.shared'
27
+ '..shared' -> 'home.shared'
28
+ 'std.shared' (absolute) stays 'std.shared'
29
+ """
30
+ if not mod:
31
+ return mod
32
+ if not mod.startswith("."):
33
+ return mod # already absolute
34
+ # Count leading dots
35
+ i = 0
36
+ while i < len(mod) and mod[i] == ".":
37
+ i += 1
38
+ rel = mod[i:] # tail after dots (may be '')
39
+ base_parts = (importing_pkg or "").split(".")
40
+ # Pop one level per dot
41
+ up = i - 1 # '.x' means stay at same depth + replace last segment -> up=0
42
+ if up > 0 and up <= len(base_parts):
43
+ base_parts = base_parts[:len(base_parts) - up]
44
+ elif up > len(base_parts):
45
+ base_parts = []
46
+ if rel:
47
+ return ".".join([p for p in base_parts if p] + [rel])
48
+ return ".".join([p for p in base_parts if p])
49
+
50
+ def _derive_package_name(prog: Program, src_path: Path, module_root: Path | None) -> str:
51
+ """
52
+ If the source did not declare `package`, derive one from the path:
53
+ - If module_root is given and src_path is under it: use relative path (dots)
54
+ - Else: use file stem
55
+ """
56
+ if getattr(prog, "package", None):
57
+ return prog.package # declared
58
+ if module_root:
59
+ try:
60
+ rel = src_path.resolve().relative_to(module_root.resolve())
61
+ parts = list(rel.with_suffix("").parts)
62
+ if parts:
63
+ return ".".join(parts)
64
+ except Exception:
65
+ pass
66
+ return src_path.stem
67
+
68
+ def _collect_public_exports(prog: Program, pkg: str) -> Dict[Tuple[str,str,str], object]:
69
+ """
70
+ Build (pkg, kind, name) -> node for public alias/schedule in a single Program.
71
+ Accepts both Schedule nodes and transformer dicts {"type":"schedule_decl",...}.
72
+ """
73
+ out: Dict[Tuple[str,str,str], object] = {}
74
+ # Aliases
75
+ for s in prog.statements:
76
+ if isinstance(s, Alias):
77
+ if not getattr(s, "private", False):
78
+ out[(pkg, "alias", s.name)] = s
79
+ # Schedules (either dicts from transformer or Schedule nodes)
80
+ for s in prog.statements:
81
+ if isinstance(s, Schedule):
82
+ if not getattr(s, "private", False):
83
+ out[(pkg, "schedule", s.name)] = s
84
+ elif isinstance(s, dict) and s.get("type") == "schedule_decl" and not s.get("private", False):
85
+ name = s.get("name")
86
+ if isinstance(name, str) and name.strip():
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
+
94
+ return out
95
+
96
+ def _scan_hassl_files(path: Path) -> List[Path]:
97
+ if path.is_file():
98
+ return [path]
99
+ return [Path(p) for p in glob.glob(str(path / "**" / "*.hassl"), recursive=True)]
100
+
101
+ def _module_to_path(module_root: Path, module: str) -> Path:
102
+ return (module_root / Path(module.replace(".", "/"))).with_suffix(".hassl")
103
+
104
+ def _ensure_imports_loaded(programs, module_root: Path):
105
+ """If imported packages aren't parsed yet, try to load their .hassl files from module_root."""
106
+ # Track both package names (from parsed files) and module ids (from import statements)
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
+
111
+ added = True
112
+ while added:
113
+ added = False
114
+ # iterate over a snapshot; we'll append to programs
115
+ for importer_path, prog, importer_pkg in list(programs):
116
+ for imp in getattr(prog, "imports", []) or []:
117
+ if not isinstance(imp, dict) or imp.get("type") != "import":
118
+ continue
119
+
120
+ raw_mod = imp.get("module", "")
121
+ if not raw_mod or not module_root:
122
+ continue
123
+
124
+ abs_mod = _normalize_module(importer_pkg, raw_mod)
125
+
126
+ # guard: avoid re-trying the same module or self-import
127
+ if abs_mod in seen_modules or abs_mod == importer_pkg:
128
+ continue
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)
133
+ continue
134
+
135
+ candidate = _module_to_path(module_root, abs_mod)
136
+ if not candidate.exists():
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
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
+
162
+ def main():
163
+ print("[hasslc] Using CLI file:", __file__)
164
+ ap = argparse.ArgumentParser(prog="hasslc", description="HASSL Compiler")
165
+ ap.add_argument("input", help="Input .hassl file OR directory")
166
+ ap.add_argument("-o", "--out", default="./packages/out", help="Output directory root for HA package(s)")
167
+ ap.add_argument("--module-root", default=None, help="Optional root to derive package names from paths")
168
+ args = ap.parse_args()
169
+
170
+ in_path = Path(args.input)
171
+ out_root = Path(args.out)
172
+ module_root = Path(args.module_root).resolve() if args.module_root else None
173
+
174
+ src_files = _scan_hassl_files(in_path)
175
+ if not src_files:
176
+ raise SystemExit(f"[hasslc] No .hassl files found in {in_path}")
177
+
178
+ # Pass 0: parse all and assign/derive package names
179
+ programs: List[tuple[Path, Program, str]] = []
180
+ for p in src_files:
181
+ with open(p, "r", encoding="utf-8") as f:
182
+ text = f.read()
183
+ prog = parse_hassl(text)
184
+ pkg_name = _derive_package_name(prog, p, module_root)
185
+ try:
186
+ prog.package = pkg_name
187
+ except Exception:
188
+ pass
189
+ programs.append((p, prog, pkg_name))
190
+
191
+ # auto-load any missing imports from --module_root
192
+ _ensure_imports_loaded(programs, module_root)
193
+
194
+ # Pass 1: collect public exports across all programs
195
+ GLOBAL_EXPORTS: Dict[Tuple[str,str,str], object] = {}
196
+ for path, prog, pkg in programs:
197
+ GLOBAL_EXPORTS.update(_collect_public_exports(prog, pkg))
198
+
199
+ # publish global exports to analyzer
200
+ sem_analyzer.GLOBAL_EXPORTS = GLOBAL_EXPORTS
201
+
202
+ # Pass 2: analyze each program with global view
203
+ os.makedirs(out_root, exist_ok=True)
204
+ all_ir = []
205
+ for path, prog, pkg in programs:
206
+ print(f"[hasslc] Parsing {path} (package: {pkg})")
207
+ print("[hasslc] AST:", json.dumps(prog.to_dict(), indent=2))
208
+ ir = analyze(prog)
209
+ print("[hasslc] IR:", json.dumps(ir.to_dict(), indent=2))
210
+ all_ir.append((pkg, ir))
211
+
212
+ # Emit: per package subdir
213
+ for pkg, ir in all_ir:
214
+ # One-level output: flatten dotted package id into a single directory name
215
+ # e.g., home.addie.automations -> packages/out/home_addie_automations/
216
+ pkg_dir = out_root / pkg.replace(".", "_")
217
+ print(f"[hasslc] Output directory (flat): {pkg_dir}")
218
+ os.makedirs(pkg_dir, exist_ok=True)
219
+ ir_dict = ir.to_dict() if hasattr(ir, "to_dict") else ir
220
+ codegen_generate(ir_dict, str(pkg_dir))
221
+ emit_package(ir, str(pkg_dir))
222
+ with open(pkg_dir / "DEBUG_ir.json", "w", encoding="utf-8") as dbg:
223
+ dbg.write(json.dumps(ir.to_dict(), indent=2))
224
+ print(f"[hasslc] Package written to {pkg_dir}")
225
+
226
+ # Also drop a cross-project export table for debugging
227
+ with open(out_root / "DEBUG_exports.json", "w", encoding="utf-8") as fp:
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()}
234
+ json.dump(printable, fp, indent=2)
235
+ print(f"[hasslc] Global exports index written to {out_root / 'DEBUG_exports.json'}")
236
+
237
+ if __name__ == "__main__":
238
+ main()
@@ -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:
@@ -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