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.
- {hassl-0.3.1/hassl.egg-info → hassl-0.4.0}/PKG-INFO +6 -6
- {hassl-0.3.1 → hassl-0.4.0}/README.md +5 -5
- hassl-0.4.0/hassl/__init__.py +1 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/ast/nodes.py +16 -1
- hassl-0.4.0/hassl/cli.py +238 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/package.py +23 -4
- {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/rules_min.py +13 -19
- {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/hassl.lark +68 -30
- {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/transform.py +252 -345
- {hassl-0.3.1 → hassl-0.4.0}/hassl/semantics/analyzer.py +235 -12
- {hassl-0.3.1 → hassl-0.4.0/hassl.egg-info}/PKG-INFO +6 -6
- {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/SOURCES.txt +2 -1
- {hassl-0.3.1 → hassl-0.4.0}/pyproject.toml +1 -1
- {hassl-0.3.1 → hassl-0.4.0}/setup.cfg +1 -1
- {hassl-0.3.1 → hassl-0.4.0}/tests/test_imports_and_schedules.py +65 -3
- {hassl-0.3.1 → hassl-0.4.0}/tests/test_schedule_windows_codegen.py +1 -3
- hassl-0.4.0/tests/test_templates.py +56 -0
- hassl-0.3.1/hassl/__init__.py +0 -1
- hassl-0.3.1/hassl/cli.py +0 -372
- {hassl-0.3.1 → hassl-0.4.0}/LICENSE +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/MANIFEST.in +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/ast/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/generate.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/init.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/codegen/yaml_emit.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/parser/loader.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/semantics/__init__.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl/semantics/domains.py +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/dependency_links.txt +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/entry_points.txt +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/requires.txt +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/hassl.egg-info/top_level.txt +0 -0
- {hassl-0.3.1 → hassl-0.4.0}/tests/test_codegen_sync_basic.py +0 -0
- {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
|
+
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;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> **Home Assistant Simple Scripting Language**
|
|
4
4
|
|
|
5
|
-

|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
hassl-0.4.0/hassl/cli.py
ADDED
|
@@ -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
|
-
|
|
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)
|
|
@@ -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:
|
|
@@ -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
|