hadsync 0.2.2__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.
hadsync/state.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ STATE_FILENAME = ".hadsync-state.json"
8
+
9
+
10
+ def _load(workspace: Path) -> dict:
11
+ path = workspace / STATE_FILENAME
12
+ if not path.exists():
13
+ return {"dashboards": {}}
14
+ try:
15
+ return json.loads(path.read_text(encoding="utf-8"))
16
+ except Exception:
17
+ return {"dashboards": {}}
18
+
19
+
20
+ def _save(workspace: Path, state: dict) -> None:
21
+ path = workspace / STATE_FILENAME
22
+ path.write_text(json.dumps(state, indent=2), encoding="utf-8")
23
+
24
+
25
+ def record_pull(
26
+ workspace: Path,
27
+ url_path: str,
28
+ ha_config_hash: str | None = None,
29
+ ) -> None:
30
+ state = _load(workspace)
31
+ existing = state["dashboards"].get(url_path, {})
32
+ entry: dict = {
33
+ **existing,
34
+ "last_pull": datetime.now(timezone.utc).isoformat(),
35
+ }
36
+ if ha_config_hash is not None:
37
+ entry["ha_config_hash"] = ha_config_hash
38
+ state["dashboards"][url_path] = entry
39
+ _save(workspace, state)
40
+
41
+
42
+ def record_push(workspace: Path, url_path: str) -> None:
43
+ state = _load(workspace)
44
+ existing = state["dashboards"].get(url_path, {})
45
+ state["dashboards"][url_path] = {
46
+ **existing,
47
+ "last_push": datetime.now(timezone.utc).isoformat(),
48
+ }
49
+ _save(workspace, state)
50
+
51
+
52
+ def get_dashboard_state(workspace: Path, url_path: str) -> dict:
53
+ return _load(workspace)["dashboards"].get(url_path, {})
54
+
55
+
56
+ def get_all_states(workspace: Path) -> dict[str, dict]:
57
+ return _load(workspace).get("dashboards", {})
hadsync/validator.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ class Severity(str, Enum):
10
+ ERROR = "ERROR"
11
+ WARN = "WARN"
12
+
13
+
14
+ @dataclass
15
+ class ValidationIssue:
16
+ severity: Severity
17
+ message: str
18
+ line: Optional[int] = None
19
+
20
+ def __str__(self) -> str:
21
+ loc = f" (line {self.line})" if self.line else ""
22
+ return f"[{self.severity.value}] {self.message}{loc}"
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Phase 1 — YAML syntax + structural checks
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def validate(path: Path) -> list[ValidationIssue]:
30
+ """Phase 1: YAML syntax + structural checks. No HA connection required."""
31
+ if not path.exists():
32
+ return [ValidationIssue(Severity.ERROR, f"File not found: {path}")]
33
+
34
+ from ruamel.yaml import YAML
35
+ _yaml = YAML()
36
+ try:
37
+ config = _yaml.load(path)
38
+ except Exception as e:
39
+ mark = getattr(getattr(e, "problem_mark", None), "line", None)
40
+ line = mark + 1 if mark is not None else None
41
+ problem = getattr(e, "problem", str(e))
42
+ return [ValidationIssue(Severity.ERROR, f"YAML syntax error: {problem}", line)]
43
+
44
+ if config is None:
45
+ return [ValidationIssue(Severity.ERROR, "File is empty")]
46
+
47
+ if not isinstance(config, dict):
48
+ return [ValidationIssue(Severity.ERROR, "Config must be a YAML mapping, not a list or scalar")]
49
+
50
+ issues: list[ValidationIssue] = []
51
+
52
+ if "views" not in config:
53
+ issues.append(ValidationIssue(Severity.ERROR, "Missing required key: 'views'"))
54
+ return issues
55
+
56
+ views = config["views"]
57
+ if not isinstance(views, list):
58
+ issues.append(ValidationIssue(Severity.ERROR, "'views' must be a list"))
59
+ return issues
60
+
61
+ if len(views) == 0:
62
+ issues.append(ValidationIssue(
63
+ Severity.WARN,
64
+ "Dashboard has 0 views — pushing will wipe all content from this dashboard in HA",
65
+ ))
66
+
67
+ for i, view in enumerate(views):
68
+ if not isinstance(view, dict):
69
+ issues.append(ValidationIssue(Severity.ERROR, f"views[{i}] must be a mapping"))
70
+
71
+ return issues
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Phase 2 — Entity ID existence checks against local cache
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def validate_entities(
79
+ path: Path,
80
+ workspace: Path,
81
+ warn_on_unknown: bool = True,
82
+ max_age_days: int = 7,
83
+ ) -> list[ValidationIssue]:
84
+ """Phase 2: check entity_id references in a lovelace.yaml against the cache.
85
+
86
+ Returns an empty list if the entity cache does not exist (Phase 2 silently
87
+ skipped — user must run 'hadsync entities refresh' to enable this check).
88
+ """
89
+ from hadsync.entities import (
90
+ cache_age_days, entity_id_exists, extract_entity_ids, load_entity_cache,
91
+ )
92
+
93
+ issues: list[ValidationIssue] = []
94
+
95
+ # Require an existing cache — skip silently if absent (not an error)
96
+ cache = load_entity_cache(workspace)
97
+ if not cache.get("entities"):
98
+ return []
99
+
100
+ age = cache_age_days(workspace)
101
+ if age is not None and age > max_age_days:
102
+ issues.append(ValidationIssue(
103
+ Severity.WARN,
104
+ f"Entity cache is {age:.0f} day(s) old (limit: {max_age_days}) — "
105
+ "run 'hadsync entities refresh'",
106
+ ))
107
+
108
+ # Load raw YAML to keep ruamel.yaml line info for reporting
109
+ from ruamel.yaml import YAML
110
+ _yaml = YAML()
111
+ try:
112
+ config = _yaml.load(path)
113
+ except Exception:
114
+ return issues # syntax errors are caught by Phase 1 validate()
115
+
116
+ if not isinstance(config, dict):
117
+ return issues
118
+
119
+ seen: set[str] = set()
120
+ severity = Severity.WARN if warn_on_unknown else Severity.ERROR
121
+
122
+ for entity_id, line in extract_entity_ids(config):
123
+ if entity_id in seen:
124
+ continue
125
+ seen.add(entity_id)
126
+ if not entity_id_exists(workspace, entity_id):
127
+ issues.append(ValidationIssue(
128
+ severity,
129
+ f"Unknown entity: {entity_id}",
130
+ line,
131
+ ))
132
+
133
+ return issues
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Phase 3 — Card type and required-field schema checks
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def validate_schema(
141
+ path: Path,
142
+ custom_card_types: list[str] | None = None,
143
+ ) -> list[ValidationIssue]:
144
+ """Phase 3: check card types and required fields against the bundled schema.
145
+
146
+ custom:* prefixed types are always allowed. Additional prefixes can be
147
+ passed via custom_card_types (mirrors validation.custom_card_types config).
148
+ """
149
+ from ruamel.yaml import YAML
150
+ from hadsync.schema import validate_cards
151
+
152
+ _yaml = YAML()
153
+ try:
154
+ config = _yaml.load(path)
155
+ except Exception:
156
+ return [] # syntax errors caught by Phase 1
157
+
158
+ if not isinstance(config, dict):
159
+ return []
160
+
161
+ return [
162
+ ValidationIssue(Severity[sev], msg, line)
163
+ for sev, msg, line in validate_cards(config, custom_card_types)
164
+ ]
165
+
166
+
167
+ def has_errors(issues: list[ValidationIssue]) -> bool:
168
+ return any(i.severity == Severity.ERROR for i in issues)
hadsync/watcher.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from watchdog.events import FileSystemEventHandler
10
+ from watchdog.observers import Observer
11
+
12
+ import hadsync.output as output
13
+ from hadsync.config import Config
14
+ from hadsync.converter import LOVELACE_FILENAME, count_cards, normalize, yaml_file_to_config
15
+ from hadsync.validator import Severity, has_errors, validate, validate_entities, validate_schema
16
+
17
+
18
+ class _DashboardHandler(FileSystemEventHandler):
19
+ def __init__(
20
+ self,
21
+ cfg: Config,
22
+ auto_push: bool,
23
+ filter_id: Optional[str],
24
+ ) -> None:
25
+ self._cfg = cfg
26
+ self._auto_push = auto_push
27
+ self._filter_id = filter_id
28
+ self._last: dict[str, float] = {}
29
+ self._debounce = 1.5 # seconds — absorbs rapid consecutive save events
30
+
31
+ def on_modified(self, event) -> None: # type: ignore[override]
32
+ if event.is_directory:
33
+ return
34
+ path = Path(event.src_path)
35
+ if path.name != LOVELACE_FILENAME:
36
+ return
37
+
38
+ url_path = path.parent.name
39
+ if self._filter_id and url_path != self._filter_id:
40
+ return
41
+
42
+ now = time.monotonic()
43
+ key = str(path)
44
+ if now - self._last.get(key, 0) < self._debounce:
45
+ return
46
+ self._last[key] = now
47
+
48
+ self._handle(path, url_path)
49
+
50
+ def _handle(self, yaml_path: Path, url_path: str) -> None:
51
+ ts = datetime.now().strftime("%H:%M:%S")
52
+ output.console.print(f"\n[dim]{ts}[/dim] [bold cyan]{url_path}[/bold cyan] saved")
53
+
54
+ try:
55
+ self._validate_and_push(yaml_path, url_path)
56
+ except Exception as e:
57
+ # Catch unexpected errors so the watchdog observer thread keeps running.
58
+ # A crash here would silently stop all future watch events.
59
+ output.error(f" Watch handler crashed unexpectedly: {e}")
60
+
61
+ def _validate_and_push(self, yaml_path: Path, url_path: str) -> None:
62
+ cfg = self._cfg
63
+ issues = validate(yaml_path)
64
+ issues += validate_entities(
65
+ yaml_path, cfg.workspace,
66
+ warn_on_unknown=cfg.validation.warn_on_unknown_entities,
67
+ max_age_days=cfg.validation.entity_cache_max_age_days,
68
+ )
69
+ issues += validate_schema(yaml_path, cfg.validation.custom_card_types)
70
+
71
+ if not issues:
72
+ output.success(" Validation passed")
73
+ else:
74
+ for issue in issues:
75
+ fn = output.error if issue.severity == Severity.ERROR else output.warn
76
+ fn(f" {issue}")
77
+
78
+ if self._auto_push and not has_errors(issues):
79
+ try:
80
+ asyncio.run(self._push(yaml_path, url_path))
81
+ except Exception as e:
82
+ output.error(f" Auto-push failed: {e}")
83
+
84
+ async def _push(self, yaml_path: Path, url_path: str) -> None:
85
+ from hadsync.ha_ws import HAWebSocketClient
86
+ from hadsync.state import record_push
87
+
88
+ local_config = normalize(yaml_file_to_config(yaml_path))
89
+ async with HAWebSocketClient(self._cfg.ha_url, self._cfg.ha_token) as client:
90
+ ha_config = normalize(await client.get_dashboard_config(url_path))
91
+ if local_config == ha_config:
92
+ output.info(" Already up to date — nothing pushed")
93
+ return
94
+ await client.save_dashboard_config(url_path, local_config)
95
+ record_push(self._cfg.workspace, url_path)
96
+ n_views, n_cards = count_cards(local_config)
97
+ output.success(f" Auto-pushed ({n_views} views, {n_cards} cards)")
98
+
99
+
100
+ def run_watch(cfg: Config, auto_push: bool, filter_id: Optional[str]) -> None:
101
+ """Start the filesystem watcher. Blocks until Ctrl-C."""
102
+ handler = _DashboardHandler(cfg, auto_push, filter_id)
103
+ observer = Observer()
104
+ observer.schedule(handler, str(cfg.workspace), recursive=True)
105
+ observer.start()
106
+
107
+ mode = "validate + auto-push" if auto_push else "validate on save"
108
+ output.success(f"Watching {cfg.workspace} [{mode}]")
109
+ output.info("Press Ctrl-C to stop.")
110
+
111
+ try:
112
+ while True:
113
+ time.sleep(0.5)
114
+ except KeyboardInterrupt:
115
+ pass
116
+ finally:
117
+ observer.stop()
118
+ observer.join()
119
+ output.console.print("\n[dim]Watch stopped.[/dim]")