hassl 0.2.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 +0 -0
- hassl/ast/__init__.py +0 -0
- hassl/ast/nodes.py +34 -0
- hassl/cli.py +42 -0
- hassl/codegen/__init__.py +22 -0
- hassl/codegen/package.py +335 -0
- hassl/codegen/rules_min.py +663 -0
- hassl/codegen/yaml_emit.py +77 -0
- hassl/parser/__init__.py +0 -0
- hassl/parser/transform.py +272 -0
- hassl/semantics/__init__.py +0 -0
- hassl/semantics/analyzer.py +145 -0
- hassl/semantics/domains.py +8 -0
- hassl-0.2.0.dist-info/METADATA +167 -0
- hassl-0.2.0.dist-info/RECORD +19 -0
- hassl-0.2.0.dist-info/WHEEL +5 -0
- hassl-0.2.0.dist-info/entry_points.txt +2 -0
- hassl-0.2.0.dist-info/licenses/LICENSE +21 -0
- hassl-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# yaml_emit.py
|
|
2
|
+
from typing import Any, Dict, Union
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
HEADER = "# Generated by HASSL codegen\n"
|
|
7
|
+
|
|
8
|
+
def ensure_dir(path: Union[str, Path]) -> None:
|
|
9
|
+
Path(path).mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
def _deep_update(dst: Dict, src: Dict) -> Dict:
|
|
12
|
+
"""Recursively merge src into dst (in-place) and return dst."""
|
|
13
|
+
for k, v in src.items():
|
|
14
|
+
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
|
15
|
+
_deep_update(dst[k], v)
|
|
16
|
+
else:
|
|
17
|
+
dst[k] = v
|
|
18
|
+
return dst
|
|
19
|
+
|
|
20
|
+
def _load_yaml_or_empty(path: Path) -> Dict:
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return {}
|
|
23
|
+
try:
|
|
24
|
+
import yaml
|
|
25
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
26
|
+
return data if isinstance(data, dict) else {}
|
|
27
|
+
except Exception:
|
|
28
|
+
# If existing file is not valid YAML, ignore/overwrite
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
def _dump_yaml(
|
|
32
|
+
path: Union[str, Path],
|
|
33
|
+
data: Any,
|
|
34
|
+
*,
|
|
35
|
+
header: bool = True,
|
|
36
|
+
merge: bool = True,
|
|
37
|
+
ensure_sections: bool = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Write YAML to `path`.
|
|
41
|
+
|
|
42
|
+
- merge=True: deep-merge into existing file (if any) instead of overwriting.
|
|
43
|
+
- header=True: prepend the standard HASSL header line.
|
|
44
|
+
- ensure_sections=True: guarantee HA helper sections exist (input_text/input_boolean/input_number).
|
|
45
|
+
|
|
46
|
+
Backward compatible with previous signature: _dump_yaml(path, data)
|
|
47
|
+
"""
|
|
48
|
+
p = Path(path)
|
|
49
|
+
ensure_dir(p.parent)
|
|
50
|
+
|
|
51
|
+
out: Dict = {}
|
|
52
|
+
if merge:
|
|
53
|
+
out = _load_yaml_or_empty(p)
|
|
54
|
+
|
|
55
|
+
# Deep-merge user data into 'out'
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
_deep_update(out, data)
|
|
58
|
+
else:
|
|
59
|
+
# If non-dict, just replace (can still honor header)
|
|
60
|
+
out = data
|
|
61
|
+
|
|
62
|
+
if ensure_sections and isinstance(out, dict):
|
|
63
|
+
out.setdefault("input_text", {})
|
|
64
|
+
out.setdefault("input_boolean", {})
|
|
65
|
+
out.setdefault("input_number", {})
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
import yaml
|
|
69
|
+
body = yaml.safe_dump(out, sort_keys=False)
|
|
70
|
+
p.write_text((HEADER if header else "") + body)
|
|
71
|
+
except Exception:
|
|
72
|
+
# Fallback to JSON if PyYAML not available
|
|
73
|
+
import json
|
|
74
|
+
p.write_text(
|
|
75
|
+
(HEADER if header else "")
|
|
76
|
+
+ json.dumps(out, indent=2)
|
|
77
|
+
)
|
hassl/parser/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from lark import Transformer, v_args, Token
|
|
2
|
+
from ..ast import nodes
|
|
3
|
+
|
|
4
|
+
def _atom(val):
|
|
5
|
+
if isinstance(val, Token):
|
|
6
|
+
t = val.type
|
|
7
|
+
s = str(val)
|
|
8
|
+
if t in ("INT",):
|
|
9
|
+
return int(s)
|
|
10
|
+
if t in ("SIGNED_NUMBER","NUMBER"):
|
|
11
|
+
try:
|
|
12
|
+
return int(s)
|
|
13
|
+
except ValueError:
|
|
14
|
+
return float(s)
|
|
15
|
+
if t in ("CNAME", "STATE", "UNIT", "ONOFF", "DIMMER", "ATTRIBUTE", "SHARED", "ALL"):
|
|
16
|
+
return s
|
|
17
|
+
if t == "STRING":
|
|
18
|
+
return s[1:-1]
|
|
19
|
+
return val
|
|
20
|
+
|
|
21
|
+
def _to_str(x):
|
|
22
|
+
return str(x) if not isinstance(x, Token) else str(x)
|
|
23
|
+
|
|
24
|
+
@v_args(inline=True)
|
|
25
|
+
class HasslTransformer(Transformer):
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.stmts = []
|
|
29
|
+
|
|
30
|
+
# --- Program / Aliases / Syncs ---
|
|
31
|
+
def start(self, *stmts):
|
|
32
|
+
# We’ve been accumulating into self.stmts to preserve order.
|
|
33
|
+
return nodes.Program(statements=self.stmts)
|
|
34
|
+
|
|
35
|
+
def alias(self, name, entity):
|
|
36
|
+
a = nodes.Alias(name=str(name), entity=str(entity))
|
|
37
|
+
self.stmts.append(a)
|
|
38
|
+
return a
|
|
39
|
+
|
|
40
|
+
def sync(self, synctype, members, name, syncopts=None):
|
|
41
|
+
invert = []
|
|
42
|
+
if isinstance(syncopts, list):
|
|
43
|
+
invert = syncopts
|
|
44
|
+
s = nodes.Sync(kind=str(synctype), members=members, name=str(name), invert=invert)
|
|
45
|
+
self.stmts.append(s)
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
def synctype(self, tok): return str(tok)
|
|
49
|
+
def syncopts(self, *args): return list(args)[-1] if args else []
|
|
50
|
+
def entity_list(self, *entities): return [str(e) for e in entities]
|
|
51
|
+
def member(self, val): return val
|
|
52
|
+
def entity(self, *parts): return ".".join(str(p) for p in parts)
|
|
53
|
+
|
|
54
|
+
# --- Rules / if_clause ---
|
|
55
|
+
def rule(self, name, *clauses):
|
|
56
|
+
# clauses may include IfClause nodes AND schedule_* dicts (we keep both).
|
|
57
|
+
r = nodes.Rule(name=str(name), clauses=list(clauses))
|
|
58
|
+
self.stmts.append(r)
|
|
59
|
+
return r
|
|
60
|
+
|
|
61
|
+
# if_clause: "if" "(" expr qualifier? ")" qualifier? "then" actions
|
|
62
|
+
def if_clause(self, *parts):
|
|
63
|
+
actions = parts[-1]
|
|
64
|
+
core = list(parts[:-1])
|
|
65
|
+
expr = core[0]
|
|
66
|
+
quals = [q for q in core[1:] if isinstance(q, dict) and "not_by" in q]
|
|
67
|
+
|
|
68
|
+
cond = {"expr": expr}
|
|
69
|
+
if quals:
|
|
70
|
+
cond.update(quals[-1]) # prefer last qualifier
|
|
71
|
+
return nodes.IfClause(condition=cond, actions=actions)
|
|
72
|
+
|
|
73
|
+
# --- Condition & boolean ops ---
|
|
74
|
+
def condition(self, expr, qual=None):
|
|
75
|
+
cond = {"expr": expr}
|
|
76
|
+
if qual is not None:
|
|
77
|
+
cond.update(qual)
|
|
78
|
+
return cond
|
|
79
|
+
|
|
80
|
+
def qualifier(self, *args):
|
|
81
|
+
sargs = [str(a) for a in args]
|
|
82
|
+
if len(sargs) == 1:
|
|
83
|
+
return {"not_by": sargs[0]}
|
|
84
|
+
if len(sargs) == 2 and sargs[0] == "rule":
|
|
85
|
+
return {"not_by": {"rule": sargs[1]}}
|
|
86
|
+
return {"not_by": "this"}
|
|
87
|
+
|
|
88
|
+
def or_(self, left, right): return {"op": "or", "left": left, "right": right}
|
|
89
|
+
def and_(self, left, right): return {"op": "and", "left": left, "right": right}
|
|
90
|
+
def not_(self, term): return {"op": "not", "value": term}
|
|
91
|
+
|
|
92
|
+
def comparison(self, left, op=None, right=None):
|
|
93
|
+
if op is None:
|
|
94
|
+
return left
|
|
95
|
+
return {"op": str(op), "left": left, "right": right}
|
|
96
|
+
|
|
97
|
+
def bare_operand(self, val): return _atom(val)
|
|
98
|
+
def operand(self, val): return _atom(val)
|
|
99
|
+
def OP(self, tok): return str(tok)
|
|
100
|
+
|
|
101
|
+
# --- Actions ---
|
|
102
|
+
def actions(self, *acts): return list(acts)
|
|
103
|
+
def action(self, act): return act
|
|
104
|
+
|
|
105
|
+
def dur(self, n, unit):
|
|
106
|
+
return f"{int(str(n))}{str(unit)}"
|
|
107
|
+
|
|
108
|
+
def assign(self, name, state, *for_parts):
|
|
109
|
+
act = {"type": "assign", "target": str(name), "state": str(state)}
|
|
110
|
+
if for_parts:
|
|
111
|
+
act["for"] = for_parts[0]
|
|
112
|
+
return act
|
|
113
|
+
|
|
114
|
+
def attr_assign(self, *parts):
|
|
115
|
+
value = _atom(parts[-1])
|
|
116
|
+
cnames = [str(p) for p in parts[:-1]]
|
|
117
|
+
attr = cnames[-1]
|
|
118
|
+
entity = ".".join(cnames[:-1])
|
|
119
|
+
return {"type": "attr_assign", "entity": entity, "attr": attr, "value": value}
|
|
120
|
+
|
|
121
|
+
def waitact(self, cond, dur, action):
|
|
122
|
+
return {"type": "wait", "condition": cond, "for": dur, "then": action}
|
|
123
|
+
|
|
124
|
+
# Robust rule control
|
|
125
|
+
def rulectrl(self, *parts):
|
|
126
|
+
from lark import Token
|
|
127
|
+
|
|
128
|
+
def s(x): # normalize tokens -> str/primitive
|
|
129
|
+
return str(x) if isinstance(x, Token) else x
|
|
130
|
+
|
|
131
|
+
vals = [s(p) for p in parts]
|
|
132
|
+
|
|
133
|
+
# op
|
|
134
|
+
op = None
|
|
135
|
+
for v in vals:
|
|
136
|
+
if isinstance(v, str) and v.lower() in ("disable", "enable"):
|
|
137
|
+
op = v.lower()
|
|
138
|
+
break
|
|
139
|
+
if not op:
|
|
140
|
+
op = "disable"
|
|
141
|
+
|
|
142
|
+
# name
|
|
143
|
+
name = None
|
|
144
|
+
keywords = {"rule", "for", "until", "disable", "enable"}
|
|
145
|
+
if "rule" in [str(v).lower() for v in vals if isinstance(v, str)]:
|
|
146
|
+
rs = [i for i, v in enumerate(vals) if isinstance(v, str) and v.lower() == "rule"]
|
|
147
|
+
for i in rs:
|
|
148
|
+
if i + 1 < len(vals):
|
|
149
|
+
name = vals[i + 1]
|
|
150
|
+
break
|
|
151
|
+
if name is None:
|
|
152
|
+
for v in vals:
|
|
153
|
+
if isinstance(v, str) and v.lower() not in keywords:
|
|
154
|
+
name = v
|
|
155
|
+
break
|
|
156
|
+
if name is None:
|
|
157
|
+
raise ValueError(f"rulectrl: could not determine rule name from parts={vals!r}")
|
|
158
|
+
|
|
159
|
+
# tail
|
|
160
|
+
payload = {}
|
|
161
|
+
try:
|
|
162
|
+
start_idx = vals.index(name) + 1
|
|
163
|
+
except ValueError:
|
|
164
|
+
start_idx = 1
|
|
165
|
+
|
|
166
|
+
i = start_idx
|
|
167
|
+
while i < len(vals):
|
|
168
|
+
v = vals[i]
|
|
169
|
+
vlow = str(v).lower() if isinstance(v, str) else ""
|
|
170
|
+
if vlow == "for" and i + 1 < len(vals):
|
|
171
|
+
payload["for"] = vals[i + 1]
|
|
172
|
+
i += 2
|
|
173
|
+
continue
|
|
174
|
+
if vlow == "until" and i + 1 < len(vals):
|
|
175
|
+
payload["until"] = vals[i + 1]
|
|
176
|
+
i += 2
|
|
177
|
+
continue
|
|
178
|
+
i += 1
|
|
179
|
+
|
|
180
|
+
if not payload:
|
|
181
|
+
units = ("ms", "s", "m", "h", "d")
|
|
182
|
+
for v in vals[start_idx:]:
|
|
183
|
+
if isinstance(v, str) and any(v.endswith(u) for u in units):
|
|
184
|
+
payload["for"] = v
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
if not payload:
|
|
188
|
+
payload["for"] = "0s"
|
|
189
|
+
|
|
190
|
+
return {"type": "rule_ctrl", "op": op, "rule": str(name), **payload}
|
|
191
|
+
|
|
192
|
+
def tagact(self, name, val):
|
|
193
|
+
return {"type": "tag", "name": str(name), "value": _atom(val)}
|
|
194
|
+
|
|
195
|
+
# ======================
|
|
196
|
+
# Schedules (composable)
|
|
197
|
+
# ======================
|
|
198
|
+
|
|
199
|
+
# schedule_decl: SCHEDULE CNAME ":" schedule_clause+
|
|
200
|
+
def schedule_decl(self, *parts):
|
|
201
|
+
# parts may look like:
|
|
202
|
+
# (Token('SCHEDULE','schedule'), Token('CNAME','wake_hours'), <clause>...)
|
|
203
|
+
# or, depending on Lark settings, possibly without the SCHEDULE token.
|
|
204
|
+
idx = 0
|
|
205
|
+
|
|
206
|
+
# Skip leading SCHEDULE token if present
|
|
207
|
+
if idx < len(parts) and isinstance(parts[idx], Token) and parts[idx].type == "SCHEDULE":
|
|
208
|
+
idx += 1
|
|
209
|
+
|
|
210
|
+
# Grab the name (must be CNAME)
|
|
211
|
+
if idx >= len(parts):
|
|
212
|
+
raise ValueError("schedule_decl: missing schedule name")
|
|
213
|
+
name_tok = parts[idx]
|
|
214
|
+
name = str(name_tok)
|
|
215
|
+
idx += 1
|
|
216
|
+
|
|
217
|
+
# Some setups might surface ':' as a token—skip if present
|
|
218
|
+
if idx < len(parts) and isinstance(parts[idx], Token) and str(parts[idx]) == ":":
|
|
219
|
+
idx += 1
|
|
220
|
+
|
|
221
|
+
# Remaining items are (already-transformed) schedule_clause dicts
|
|
222
|
+
clauses = [c for c in parts[idx:] if isinstance(c, dict) and c.get("type") == "schedule_clause"]
|
|
223
|
+
|
|
224
|
+
node = {"type": "schedule_decl", "name": name, "clauses": clauses}
|
|
225
|
+
self.stmts.append(node)
|
|
226
|
+
return node
|
|
227
|
+
|
|
228
|
+
# rule_schedule_use: SCHEDULE USE name_list ";"
|
|
229
|
+
def rule_schedule_use(self, _sched_kw, _use_kw, names, _semi=None):
|
|
230
|
+
return {"type": "schedule_use", "names": [str(n) for n in names]}
|
|
231
|
+
|
|
232
|
+
# rule_schedule_inline: SCHEDULE schedule_clause+
|
|
233
|
+
def rule_schedule_inline(self, _sched_kw, *clauses):
|
|
234
|
+
clist = [c for c in clauses if isinstance(c, dict) and c.get("type") == "schedule_clause"]
|
|
235
|
+
return {"type": "schedule_inline", "clauses": clist}
|
|
236
|
+
|
|
237
|
+
# schedule_clause: schedule_op FROM time_spec schedule_end? ";"
|
|
238
|
+
def schedule_clause(self, op, _from_kw, start, end=None, _semi=None):
|
|
239
|
+
d = {"type": "schedule_clause", "op": str(op), "from": start}
|
|
240
|
+
if isinstance(end, dict):
|
|
241
|
+
d.update(end) # {"to": ...} or {"until": ...}
|
|
242
|
+
return d
|
|
243
|
+
|
|
244
|
+
# schedule_op: ENABLE | DISABLE
|
|
245
|
+
def schedule_op(self, tok):
|
|
246
|
+
return str(tok).lower()
|
|
247
|
+
|
|
248
|
+
# schedule_end: TO time_spec -> schedule_to
|
|
249
|
+
def schedule_to(self, _to_kw, ts):
|
|
250
|
+
return {"to": ts}
|
|
251
|
+
|
|
252
|
+
# schedule_end: UNTIL time_spec -> schedule_until
|
|
253
|
+
def schedule_until(self, _until_kw, ts):
|
|
254
|
+
return {"until": ts}
|
|
255
|
+
|
|
256
|
+
# name_list: CNAME ("," CNAME)*
|
|
257
|
+
def name_list(self, *names):
|
|
258
|
+
return [str(n) for n in names]
|
|
259
|
+
|
|
260
|
+
# time_spec: TIME_HHMM -> time_clock
|
|
261
|
+
def time_clock(self, tok):
|
|
262
|
+
return {"kind": "clock", "value": str(tok)}
|
|
263
|
+
|
|
264
|
+
# sun_spec: (SUNRISE|SUNSET) OFFSET? -> time_sun
|
|
265
|
+
def time_sun(self, event_tok, offset_tok=None):
|
|
266
|
+
event = str(event_tok).lower()
|
|
267
|
+
off = str(offset_tok) if offset_tok is not None else "0s"
|
|
268
|
+
return {"kind": "sun", "event": event, "offset": off}
|
|
269
|
+
|
|
270
|
+
# Unwrap rule_clause so clauses list contains IfClause nodes and/or dicts
|
|
271
|
+
def rule_clause(self, item):
|
|
272
|
+
return item
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Dict, List, Any, Optional
|
|
3
|
+
from ..ast.nodes import Program, Alias, Sync, Rule
|
|
4
|
+
from .domains import DOMAIN_PROPS, domain_of
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class IRSyncedProp:
|
|
8
|
+
name: str
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class IRSync:
|
|
12
|
+
name: str
|
|
13
|
+
kind: str
|
|
14
|
+
members: List[str]
|
|
15
|
+
invert: List[str]
|
|
16
|
+
properties: List[IRSyncedProp]
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class IRRule:
|
|
20
|
+
name: str
|
|
21
|
+
clauses: List[dict]
|
|
22
|
+
schedule_uses: Optional[List[str]] = None
|
|
23
|
+
schedules_inline: Optional[List[dict]] = None
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class IRProgram:
|
|
27
|
+
aliases: Dict[str, str]
|
|
28
|
+
syncs: List[IRSync]
|
|
29
|
+
rules: List[IRRule]
|
|
30
|
+
schedules: Optional[Dict[str, List[dict]]] = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self):
|
|
33
|
+
return {
|
|
34
|
+
"aliases": self.aliases,
|
|
35
|
+
"syncs": [{
|
|
36
|
+
"name": s.name, "kind": s.kind, "members": s.members,
|
|
37
|
+
"invert": s.invert, "properties": [p.name for p in s.properties]
|
|
38
|
+
} for s in self.syncs],
|
|
39
|
+
"rules": [{
|
|
40
|
+
"name": r.name,
|
|
41
|
+
"clauses": r.clauses,
|
|
42
|
+
"schedule_uses": r.schedule_uses or [],
|
|
43
|
+
"schedules_inline": r.schedules_inline or []
|
|
44
|
+
} for r in self.rules],
|
|
45
|
+
"schedules": self.schedules or {},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def _resolve_alias(e: str, amap: Dict[str,str]) -> str:
|
|
49
|
+
if "." not in e and e in amap: return amap[e]
|
|
50
|
+
return e
|
|
51
|
+
|
|
52
|
+
def _walk_alias(obj: Any, amap: Dict[str,str]) -> Any:
|
|
53
|
+
if isinstance(obj, dict): return {k:_walk_alias(v,amap) for k,v in obj.items()}
|
|
54
|
+
if isinstance(obj, list): return [_walk_alias(x,amap) for x in obj]
|
|
55
|
+
if isinstance(obj, str) and "." not in obj and obj in amap: return amap[obj]
|
|
56
|
+
return obj
|
|
57
|
+
|
|
58
|
+
def _props_for_sync(kind: str, members: List[str]) -> List[IRSyncedProp]:
|
|
59
|
+
domains = [domain_of(m) for m in members]
|
|
60
|
+
prop_sets = [DOMAIN_PROPS.get(d, set()) for d in domains]
|
|
61
|
+
if kind == "shared":
|
|
62
|
+
if not prop_sets: return []
|
|
63
|
+
shared = set.intersection(*map(set, prop_sets))
|
|
64
|
+
return [IRSyncedProp(p) for p in sorted(shared)]
|
|
65
|
+
if kind == "all":
|
|
66
|
+
from collections import Counter
|
|
67
|
+
c = Counter()
|
|
68
|
+
for s in prop_sets:
|
|
69
|
+
for p in s: c[p]+=1
|
|
70
|
+
return [IRSyncedProp(p) for p,n in c.items() if n>=2]
|
|
71
|
+
if kind == "onoff":
|
|
72
|
+
return [IRSyncedProp("onoff")]
|
|
73
|
+
if kind == "dimmer":
|
|
74
|
+
base = {"onoff","brightness"}
|
|
75
|
+
if all("color_temp" in s for s in prop_sets):
|
|
76
|
+
base.add("color_temp")
|
|
77
|
+
return [IRSyncedProp(p) for p in sorted(base)]
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def analyze(prog: Program) -> IRProgram:
|
|
81
|
+
# --- Aliases ---
|
|
82
|
+
amap: Dict[str,str] = {}
|
|
83
|
+
for s in prog.statements:
|
|
84
|
+
if isinstance(s, Alias):
|
|
85
|
+
amap[s.name] = s.entity
|
|
86
|
+
|
|
87
|
+
# --- Syncs ---
|
|
88
|
+
syncs: List[IRSync] = []
|
|
89
|
+
for s in prog.statements:
|
|
90
|
+
if isinstance(s, Sync):
|
|
91
|
+
mem = [_resolve_alias(m,amap) for m in s.members]
|
|
92
|
+
inv = [_resolve_alias(m,amap) for m in s.invert]
|
|
93
|
+
props = _props_for_sync(s.kind, mem)
|
|
94
|
+
syncs.append(IRSync(s.name, s.kind, mem, inv, props))
|
|
95
|
+
|
|
96
|
+
# --- Top-level schedules (from transformer) ---
|
|
97
|
+
scheds: Dict[str, List[dict]] = {}
|
|
98
|
+
for st in prog.statements:
|
|
99
|
+
# transformer emits: {"type":"schedule_decl","name":..., "clauses":[...]}
|
|
100
|
+
if isinstance(st, dict) and st.get("type") == "schedule_decl":
|
|
101
|
+
name = st.get("name")
|
|
102
|
+
clauses = st.get("clauses", []) or []
|
|
103
|
+
if isinstance(name, str) and name.strip():
|
|
104
|
+
# no aliasing inside time specs
|
|
105
|
+
scheds.setdefault(name, []).extend(clauses)
|
|
106
|
+
|
|
107
|
+
# --- Rules (with schedule use/inline) ---
|
|
108
|
+
rules: List[IRRule] = []
|
|
109
|
+
for s in prog.statements:
|
|
110
|
+
if isinstance(s, Rule):
|
|
111
|
+
clauses: List[dict] = []
|
|
112
|
+
schedule_uses: List[str] = []
|
|
113
|
+
schedules_inline: List[dict] = []
|
|
114
|
+
|
|
115
|
+
for c in s.clauses:
|
|
116
|
+
# IfClause-like items have .condition/.actions
|
|
117
|
+
if hasattr(c, "condition") and hasattr(c, "actions"):
|
|
118
|
+
cond = _walk_alias(c.condition, amap)
|
|
119
|
+
acts = _walk_alias(c.actions, amap)
|
|
120
|
+
clauses.append({"condition": cond, "actions": acts})
|
|
121
|
+
elif isinstance(c, dict) and c.get("type") == "schedule_use":
|
|
122
|
+
# {"type":"schedule_use","names":[...]}
|
|
123
|
+
schedule_uses.extend([str(n) for n in (c.get("names") or []) if isinstance(n, str)])
|
|
124
|
+
elif isinstance(c, dict) and c.get("type") == "schedule_inline":
|
|
125
|
+
# {"type":"schedule_inline","clauses":[...]}
|
|
126
|
+
for sc in c.get("clauses") or []:
|
|
127
|
+
if isinstance(sc, dict):
|
|
128
|
+
schedules_inline.append(sc)
|
|
129
|
+
else:
|
|
130
|
+
# ignore unknown fragments
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
rules.append(IRRule(
|
|
134
|
+
name=s.name,
|
|
135
|
+
clauses=clauses,
|
|
136
|
+
schedule_uses=schedule_uses,
|
|
137
|
+
schedules_inline=schedules_inline
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
return IRProgram(
|
|
141
|
+
aliases=amap,
|
|
142
|
+
syncs=syncs,
|
|
143
|
+
rules=rules,
|
|
144
|
+
schedules=scheds
|
|
145
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
DOMAIN_PROPS = {
|
|
2
|
+
"light": {"onoff", "brightness", "color_temp", "hs_color"},
|
|
3
|
+
"switch": {"onoff"},
|
|
4
|
+
"fan": {"onoff", "percentage", "preset_mode"},
|
|
5
|
+
"media_player": {"onoff", "volume", "mute", "source", "play_state"},
|
|
6
|
+
}
|
|
7
|
+
def domain_of(entity_id: str) -> str:
|
|
8
|
+
return entity_id.split(".", 1)[0]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hassl
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: HASSL: Home Assistant Simple Scripting Language
|
|
5
|
+
Home-page: https://github.com/adanowitz/hassl
|
|
6
|
+
Author: adanowitz
|
|
7
|
+
Author-email: adanowitz@gmail.com
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: lark-parser
|
|
12
|
+
Requires-Dist: jinja2
|
|
13
|
+
Requires-Dist: pyyaml
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# HASSL
|
|
17
|
+
|
|
18
|
+
> **Home Assistant Simple Scripting Language**
|
|
19
|
+
|
|
20
|
+
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/).
|
|
21
|
+
|
|
22
|
+
It compiles lightweight `.hassl` scripts into fully functional YAML packages that plug directly into Home Assistant, replacing complex automations with a clean, readable syntax.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 🚀 Features
|
|
27
|
+
|
|
28
|
+
- **Readable DSL** → write logic like natural language (`if motion && lux < 50 then light = on`)
|
|
29
|
+
- **Sync devices** → keep switches, dimmers, and fans perfectly in sync
|
|
30
|
+
- **Schedules** → declare time-based gates (`enable from 08:00 until 19:00`)
|
|
31
|
+
- **Loop-safe** → context ID tracking prevents feedback loops
|
|
32
|
+
- **Per-rule enable gates** → `disable rule` or `enable rule` dynamically
|
|
33
|
+
- **Inline waits** → `wait (!motion for 10m)` works like native HA triggers
|
|
34
|
+
- **Color temperature in Kelvin** → `light.kelvin = 2700`
|
|
35
|
+
- **Auto-reload resilience** → schedules re-evaluate automatically on HA restart
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 🧰 Example
|
|
40
|
+
|
|
41
|
+
```hassl
|
|
42
|
+
alias light = light.wesley_lamp
|
|
43
|
+
alias motion = binary_sensor.wesley_motion_motion
|
|
44
|
+
alias lux = sensor.wesley_motion_illuminance
|
|
45
|
+
|
|
46
|
+
schedule wake_hours:
|
|
47
|
+
enable from 08:00 until 19:00;
|
|
48
|
+
|
|
49
|
+
rule wesley_motion_light:
|
|
50
|
+
schedule use wake_hours;
|
|
51
|
+
if (motion && lux < 50)
|
|
52
|
+
then light = on;
|
|
53
|
+
wait (!motion for 10m) light = off
|
|
54
|
+
|
|
55
|
+
rule landing_manual_off:
|
|
56
|
+
if (light == off) not_by any_hassl
|
|
57
|
+
then disable rule wesley_motion_light for 3m
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Produces a complete Home Assistant package with:
|
|
61
|
+
|
|
62
|
+
- Helpers (`input_boolean`, `input_text`, `input_number`)
|
|
63
|
+
- Context-aware writer scripts
|
|
64
|
+
- Sync automations for linked devices
|
|
65
|
+
- Rule-based automations with schedules and `not_by` guards
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 🏗 Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/adanowitz/hassl.git
|
|
73
|
+
cd hassl
|
|
74
|
+
pip install -e .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Verify:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
hasslc --help
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## ⚙️ Usage
|
|
86
|
+
|
|
87
|
+
1. Create a `.hassl` script (e.g., `living_room.hassl`).
|
|
88
|
+
2. Compile it into a Home Assistant package:
|
|
89
|
+
```bash
|
|
90
|
+
hasslc living_room.hassl -o ./packages/living_room/
|
|
91
|
+
```
|
|
92
|
+
3. Copy the package into `/config/packages/` and reload automations.
|
|
93
|
+
|
|
94
|
+
Each `.hassl` file compiles into an isolated package — no naming collisions, no shared helpers.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 📦 Output Files
|
|
99
|
+
|
|
100
|
+
| File | Description |
|
|
101
|
+
| -------------------------- | --------------------------------------------- |
|
|
102
|
+
| `helpers_<pkg>.yaml` | Defines all helpers (booleans, text, numbers) |
|
|
103
|
+
| `scripts_<pkg>.yaml` | Writer scripts with context stamping |
|
|
104
|
+
| `sync_<pkg>_*.yaml` | Sync automations for each property |
|
|
105
|
+
| `rules_bundled_<pkg>.yaml` | Rule logic automations + schedules |
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 🧠 Concepts
|
|
110
|
+
|
|
111
|
+
| Concept | Description |
|
|
112
|
+
| ------------ | --------------------------------------------------------------- |
|
|
113
|
+
| **Alias** | Maps short names to HA entities (`alias light = light.kitchen`) |
|
|
114
|
+
| **Sync** | Keeps multiple devices aligned across on/off, brightness, etc. |
|
|
115
|
+
| **Rule** | Defines reactive logic with guards, waits, and control flow. |
|
|
116
|
+
| **Schedule** | Defines active time windows, reusable across rules. |
|
|
117
|
+
| **Tag** | Lightweight metadata stored in `input_text` helpers. |
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 🔒 Loop Safety & Context Tracking
|
|
122
|
+
|
|
123
|
+
HASSL automatically writes the **parent context ID** into helper entities before performing actions.\
|
|
124
|
+
This ensures `not_by any_hassl` and `not_by rule("name")` guards work flawlessly, preventing infinite feedback.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 🕒 Schedules That Survive Restarts
|
|
129
|
+
|
|
130
|
+
All schedules are restart-safe:
|
|
131
|
+
|
|
132
|
+
- `input_boolean.hassl_schedule_<name>` automatically re-evaluates on startup.
|
|
133
|
+
- Triggers are set for both start and end times.
|
|
134
|
+
- Missed events (like mid-day restarts) are recovered automatically.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 📚 Documentation
|
|
139
|
+
|
|
140
|
+
For full grammar and detailed semantics, see the [HASSL Language Specification](./HASSL_Specification.md).
|
|
141
|
+
|
|
142
|
+
For a hands-on guide, check out the [Quickstart](./quickstart.md).
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 🧩 Contributing
|
|
147
|
+
|
|
148
|
+
Contributions, tests, and ideas welcome!\
|
|
149
|
+
To run tests locally:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pytest tests/
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Please open pull requests for grammar improvements, new device domains, or scheduling logic.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 📄 License
|
|
160
|
+
|
|
161
|
+
MIT License © 2025\
|
|
162
|
+
Created and maintained by [@adanowitz](https://github.com/adanowitz)
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
**HASSL** — simple, reliable, human-readable automations for Home Assistant.
|
|
167
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
hassl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
hassl/cli.py,sha256=rp9C-W-Jy_Lxb9sw2mXR-3_KRLTBil5Xy5q5EYdLZT8,1503
|
|
3
|
+
hassl/ast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
hassl/ast/nodes.py,sha256=HINbu3o_hEMDD5G9TgYeqAev-Af0Vacqc_gwQQnpiy0,762
|
|
5
|
+
hassl/codegen/__init__.py,sha256=NgEw86oHlsk7cQrHz8Ttrtp68Cm7WKceTWRr02SDfjo,854
|
|
6
|
+
hassl/codegen/package.py,sha256=9dcIjszhGXV26Hwr_rKHLz6yYbElyIegfhlXGi5Q0sY,16612
|
|
7
|
+
hassl/codegen/rules_min.py,sha256=2ko9bFRZujXw7lt5k8L6UbfmTwS2PCWfub5M6Lwkg4I,28330
|
|
8
|
+
hassl/codegen/yaml_emit.py,sha256=VTNnR_uvSSqsL7kX5NyXuPUZh5FK36a_sUFsRyrQOS8,2207
|
|
9
|
+
hassl/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
hassl/parser/transform.py,sha256=dNTyPW9sOw09vBfvWMFSRRiTnOzZQWJvISV1o1aouD4,9481
|
|
11
|
+
hassl/semantics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
hassl/semantics/analyzer.py,sha256=1x2bAJXj8hsT_IBoxgfTFV0MmVMXcfjqzl_gWo-WuI8,5195
|
|
13
|
+
hassl/semantics/domains.py,sha256=-OuhA4RkOLVaLnBxhKfJFjiQMYBSTryX9KjHit_8ezI,308
|
|
14
|
+
hassl-0.2.0.dist-info/licenses/LICENSE,sha256=hPNSrn93Btl9sU4BkQlrIK1FpuFPvaemdW6-Scz-Xeo,1072
|
|
15
|
+
hassl-0.2.0.dist-info/METADATA,sha256=OBOKVzmNcsFlcVZyEp0YF6Z912-FStNKntSUd-0F96A,4929
|
|
16
|
+
hassl-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
hassl-0.2.0.dist-info/entry_points.txt,sha256=qA8jYPZlESNL6V4fJCTPOBXFeEBtjLGGsftXsSo82so,42
|
|
18
|
+
hassl-0.2.0.dist-info/top_level.txt,sha256=BPuiULeikxMI3jDVLmv3_n7yjSD9Qrq58bp9af_HixI,6
|
|
19
|
+
hassl-0.2.0.dist-info/RECORD,,
|