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.
@@ -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
+ )
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,,