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/__init__.py +1 -0
- hadsync/cli.py +979 -0
- hadsync/config.py +123 -0
- hadsync/converter.py +73 -0
- hadsync/entities.py +134 -0
- hadsync/ha_rest.py +41 -0
- hadsync/ha_ws.py +144 -0
- hadsync/output.py +20 -0
- hadsync/schema.py +138 -0
- hadsync/state.py +57 -0
- hadsync/validator.py +168 -0
- hadsync/watcher.py +119 -0
- hadsync-0.2.2.dist-info/METADATA +403 -0
- hadsync-0.2.2.dist-info/RECORD +17 -0
- hadsync-0.2.2.dist-info/WHEEL +4 -0
- hadsync-0.2.2.dist-info/entry_points.txt +2 -0
- hadsync-0.2.2.dist-info/licenses/LICENSE +21 -0
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]")
|