ha-voice-label-sync 0.2.0.dev0__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,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: ha-voice-label-sync
3
+ Version: 0.2.0.dev0
4
+ Summary: Generate voice assistant configuration from Home Assistant labels.
5
+ Author: Philippe Le Bonhomme
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/flimo44/ha-voice-label-sync
8
+ Project-URL: Documentation, https://github.com/flimo44/ha-voice-label-sync#readme
9
+ Project-URL: Issues, https://github.com/flimo44/ha-voice-label-sync/issues
10
+ Project-URL: Source, https://github.com/flimo44/ha-voice-label-sync
11
+ Keywords: home-assistant,google-assistant,voice-assistant,automation,yaml
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Home Automation
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ <img width="1024" height="559" alt="image" src="https://github.com/user-attachments/assets/6918c5ae-b95b-4d81-9108-7b2cd8acd0fd" />
23
+
24
+ ### Automatically generate your voice assistant configuration from Home Assistant labels.
25
+
26
+ [![Python checks](https://github.com/flimo44/ha-voice-label-sync/actions/workflows/python-check.yml/badge.svg?branch=main)](https://github.com/flimo44/ha-voice-label-sync/actions/workflows/python-check.yml)
27
+
28
+
29
+ > 🚧 Early development project
30
+ >
31
+ > Feedback and ideas are welcome.
32
+
33
+ ## Why?
34
+
35
+ Do you maintain dozens of Google Assistant entities manually?
36
+
37
+ Managing voice assistant configurations manually quickly becomes difficult.
38
+
39
+ HA Voice Label Sync automatically generates the required configuration from Home Assistant labels, making your labels the single source of truth.
40
+
41
+ With the current release you can:
42
+
43
+ Stop maintaining large YAML files manually.
44
+ Avoid duplicated configuration.
45
+ Keep Home Assistant as the only place where entities are managed.
46
+ Generate your configuration in seconds.
47
+
48
+ ## Current Status
49
+
50
+ ## Project status
51
+
52
+ HVLS is currently under active development.
53
+
54
+ Latest stable release: `v0.1.2`
55
+
56
+ Current development version: `0.2.0.dev0`
57
+
58
+ Current capabilities:
59
+
60
+ - Standalone CLI
61
+ - Reusable Python core
62
+ - Google Assistant YAML generation
63
+ - Dry-run support
64
+ - Safe atomic file writing
65
+ - Automatic backups and retention
66
+ - Initial Home Assistant integration skeleton
67
+
68
+ Not yet available:
69
+
70
+ - Full Home Assistant configuration UI
71
+ - Home Assistant actions and buttons
72
+ - Backup restoration from the UI
73
+ - HACS installation
74
+
75
+ HA Voice Label Sync has been designed to support multiple voice assistants.
76
+
77
+ - Currently supported :
78
+
79
+
80
+ ✅ Google Assistant
81
+
82
+ - Planned :
83
+
84
+ Amazon Alexa
85
+ Apple HomeKit
86
+
87
+ Instead of maintaining a long list of exposed entities manually, simply assign a label in Home Assistant, run the script, and let HA Voice Label Sync generate the configuration for you.
88
+
89
+
90
+ ## Features
91
+
92
+ - Select entities using a Home Assistant label
93
+ - Generate `entity_config` for Google Assistant
94
+ - Use Home Assistant friendly names
95
+ - Use Home Assistant areas as Google rooms
96
+ - Ignore disabled or hidden entities
97
+ - Dry-run mode before writing the file
98
+
99
+ ## How it works
100
+
101
+
102
+ ```
103
+ Home Assistant
104
+
105
+
106
+ Labels
107
+
108
+
109
+ Voice Assistant Sync
110
+
111
+ ┌──────┴───────┐
112
+ │ │
113
+ Google Assistant Amazon Alexa
114
+
115
+ ```
116
+
117
+ ## Generated entity_config
118
+
119
+ ```yaml
120
+ switch.prise_pompe:
121
+ expose: true
122
+ name: Pompe piscine
123
+ room: Piscine
124
+ ```
125
+
126
+
127
+ Home Assistant configuration
128
+
129
+ ```yaml
130
+ google_assistant:
131
+ project_id: YOUR_PROJECT_ID
132
+ service_account: !include google_key.json
133
+ report_state: true
134
+ expose_by_default: false
135
+ entity_config: !include google_assistant_entities.yaml
136
+ ```
137
+
138
+ ## Quick start
139
+
140
+ Installation
141
+
142
+ Option 1 – Download the script
143
+
144
+ Download the latest version of ga_label_sync.py from the repository.
145
+
146
+ Copy it to your Home Assistant scripts directory.
147
+
148
+ Typical locations are:
149
+
150
+ ```text
151
+ /config/scripts/
152
+
153
+ or
154
+
155
+ <home-assistant-config>/scripts/
156
+ ```
157
+
158
+ depending on your Home Assistant installation.
159
+
160
+ Option 2 – Clone the repository
161
+ ```bash
162
+ git clone https://github.com/flimo44/ha-voice-label-sync.git
163
+ ```
164
+ The script is located in:
165
+ ```text
166
+ scripts/ga_label_sync.py
167
+ ```
168
+
169
+ ```Tip
170
+
171
+ If you simply want to use the script, downloading scripts/ga_label_sync.py is enough.
172
+
173
+ Cloning the repository is recommended only if you plan to follow development or contribute to the project.
174
+ ```
175
+
176
+ Usage :
177
+
178
+ ### Step 1 — Add the label
179
+
180
+ <img width="455" height="375" alt="Capture d&#39;écran 2026-06-26 201643" src="https://github.com/user-attachments/assets/a262942a-5fb1-4a84-b88e-5db12eb67be8" />
181
+
182
+
183
+ ### Step 2 — Preview the generated configuration
184
+
185
+
186
+ <img width="555" height="172" alt="Capture d&#39;écran 2026-06-26 203136" src="https://github.com/user-attachments/assets/66ea6d63-983b-467b-9870-198d64b027bd" />
187
+
188
+ ```bash
189
+ python3 /config/scripts/ga_label_sync.py --label "google_assistant" --dry-run
190
+ ```
191
+
192
+ ```
193
+ # --- Couloir ---
194
+ lock.serrure_maison:
195
+ expose: true
196
+ name: Serrure porte entrée
197
+ room: Couloir
198
+
199
+ # --- Jardin ---
200
+ switch.portail:
201
+ expose: true
202
+ name: Portail
203
+ room: Jardin
204
+
205
+ # --- Piscine ---
206
+ input_select.piscine_mode_gestion:
207
+ expose: true
208
+ name: "Piscine - Mode Gestion"
209
+ room: Piscine
210
+
211
+ switch.piscine_chauffage:
212
+ expose: true
213
+ name: Pac Piscine
214
+ room: Piscine
215
+
216
+ switch.prise_pompe2:
217
+ expose: true
218
+ name: Pompe piscine
219
+ room: Piscine
220
+ ```
221
+
222
+
223
+ ### Step 3 — Generate the file
224
+ ```bash
225
+ python3 /config/scripts/ga_label_sync.py --label "google_assistant"
226
+ ```
227
+ <img width="454" height="446" alt="Capture d&#39;écran 2026-06-26 200340" src="https://github.com/user-attachments/assets/6f0938ae-13e9-4856-9b4f-7db5e8290171" />
228
+
229
+
230
+ ### Step 4 — Restart Home Assistant
231
+
232
+
233
+
234
+ ### Step 5 — Synchronize Google Home
235
+
236
+
237
+ Google Home before synchronization
238
+
239
+ <img width="1080" height="2400" alt="Screenshot_2026-06-26-19-44-33-60_2d2bd67b5e15ae98c151ac739cd6881e" src="https://github.com/user-attachments/assets/a03d1005-fde8-4186-b8ea-da2f426c5a84" />
240
+
241
+ Google Home after synchronization
242
+
243
+ <img width="1080" height="2400" alt="Screenshot_2026-06-26-19-45-48-86_2d2bd67b5e15ae98c151ac739cd6881e" src="https://github.com/user-attachments/assets/5b4d96cc-9da9-4419-9d1f-f5eac4fbec8c" />
244
+
245
+ License
246
+ MIT
@@ -0,0 +1,16 @@
1
+ ha_voice_label_sync-0.2.0.dev0.dist-info/licenses/LICENSE,sha256=ynISXJIpaGnb6casTM1HdJvEB5xdu2vqT9NBVEZKmHI,1064
2
+ hvls/__init__.py,sha256=C3jbWnJlif0RF2f1Rmj0gyno2-POrO2DO759NmJnEUw,1108
3
+ hvls/cli.py,sha256=taIaKg0938Cm1gWNKDGuA-PbUpNILE4kdMYnWOLTDwk,4063
4
+ hvls/engine.py,sha256=kAndRxaYbv1ynl2XoEY1jcdm7qvyevVO9j7dP-C-5Qc,5040
5
+ hvls/models.py,sha256=hxYqYjb1jtGPFvqV3G3xeCb-Gn_c9QPgIrWvAMZnNLY,2416
6
+ hvls/orchestrator.py,sha256=W4RZvJjpiRDFFUev9MmlxwDJebiyyXL4nSUBfdOWoBc,2598
7
+ hvls/registry.py,sha256=zp5NHRVVnDEP7q7vfOWBz0-vDViJDKqUTcfuTt9Ju-8,2422
8
+ hvls/backends/__init__.py,sha256=WOFwd9yzl4wO4T6P-hxawN9hOAkY2duTk5Quit0dgUQ,172
9
+ hvls/backends/google.py,sha256=8CQ5zyIxS-ahMSOxCgSAVQzKrsFSblC_urQmQR-nLBI,2122
10
+ hvls/writers/__init__.py,sha256=X-4Cfb-BYzPG8cQb8z5a3RWLSPmyB28SsxeNnMHQPbw,311
11
+ hvls/writers/filesystem.py,sha256=_aEYY4rqts-A-1Y9rInLBYorjoiVjEBksgaSuD9RzlU,4691
12
+ ha_voice_label_sync-0.2.0.dev0.dist-info/METADATA,sha256=U0hl8nsQSsyNda_nv4vumSBidCwSXvVSOJo9lS-8UBI,6466
13
+ ha_voice_label_sync-0.2.0.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ ha_voice_label_sync-0.2.0.dev0.dist-info/entry_points.txt,sha256=_hB7iAHYgG1zZS_LQhVakxjohB5aer31FQyUEK3DJZA,39
15
+ ha_voice_label_sync-0.2.0.dev0.dist-info/top_level.txt,sha256=i0BQbMR6mHoKNWt8Cpjjf0aA0JDeWw6sxIx-1I8yhjs,5
16
+ ha_voice_label_sync-0.2.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hvls = hvls.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 flimo44
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hvls/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Reusable core package for HA Voice Label Sync."""
2
+
3
+ from .backends import render_google_assistant, yaml_escape
4
+ from .engine import resolve_label_ids, select_entities, slugify
5
+ from .models import (
6
+ FileWriteResult,
7
+ GenerationRequest,
8
+ GenerationResult,
9
+ SelectedEntity,
10
+ SelectionResult,
11
+ WorkflowResult,
12
+ )
13
+ from .orchestrator import WorkflowError, run_google_assistant_workflow
14
+ from .registry import RegistryError, get_registry_items, load_json
15
+ from .writers import (
16
+ FileWriterError,
17
+ apply_backup_retention,
18
+ atomic_write,
19
+ create_backup,
20
+ write_with_backup,
21
+ )
22
+
23
+ __all__ = [
24
+ "GenerationRequest",
25
+ "GenerationResult",
26
+ "RegistryError",
27
+ "SelectedEntity",
28
+ "SelectionResult",
29
+ "get_registry_items",
30
+ "load_json",
31
+ "resolve_label_ids",
32
+ "select_entities",
33
+ "slugify",
34
+ "render_google_assistant",
35
+ "yaml_escape",
36
+ "FileWriteResult",
37
+ "FileWriterError",
38
+ "apply_backup_retention",
39
+ "atomic_write",
40
+ "create_backup",
41
+ "write_with_backup",
42
+ "WorkflowError",
43
+ "WorkflowResult",
44
+ "run_google_assistant_workflow",
45
+ ]
@@ -0,0 +1,8 @@
1
+ """Voice assistant backends provided by HVLS."""
2
+
3
+ from .google import render_google_assistant, yaml_escape
4
+
5
+ __all__ = [
6
+ "render_google_assistant",
7
+ "yaml_escape",
8
+ ]
@@ -0,0 +1,87 @@
1
+ """Google Assistant YAML backend for HA Voice Label Sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ from ..models import GenerationResult, SelectionResult
9
+
10
+ YAML_RESERVED_WORDS = {
11
+ "y",
12
+ "yes",
13
+ "n",
14
+ "no",
15
+ "true",
16
+ "false",
17
+ "on",
18
+ "off",
19
+ "null",
20
+ "~",
21
+ "none",
22
+ }
23
+
24
+ YAML_QUOTING_PATTERN = r'[:#{}\[\],&*?|\-<>=!%@`"\']'
25
+
26
+
27
+ def yaml_escape(value: object) -> str:
28
+ """Return a value safe for use as a YAML string scalar."""
29
+ text = str(value).strip()
30
+
31
+ if not text:
32
+ return '""'
33
+
34
+ if text.lower() in YAML_RESERVED_WORDS:
35
+ return json.dumps(text, ensure_ascii=False)
36
+
37
+ if re.fullmatch(r"[+-]?\d+(\.\d+)?", text):
38
+ return json.dumps(text, ensure_ascii=False)
39
+
40
+ if re.search(YAML_QUOTING_PATTERN, text):
41
+ return json.dumps(text, ensure_ascii=False)
42
+
43
+ return text
44
+
45
+
46
+ def render_google_assistant(
47
+ selection: SelectionResult,
48
+ ) -> GenerationResult:
49
+ """Render selected entities as Google Assistant entity configuration.
50
+
51
+ Args:
52
+ selection: Neutral entities selected by the HVLS engine.
53
+
54
+ Returns:
55
+ Generated YAML and structured generation metadata.
56
+ """
57
+ lines = [
58
+ "# Fichier généré automatiquement.",
59
+ "# Ne pas modifier à la main.",
60
+ "# Ajouter/retirer l'étiquette HA pour gérer l'exposition Google Assistant.",
61
+ "",
62
+ ]
63
+
64
+ current_room: str | None | object = object()
65
+
66
+ for entity in selection.entities:
67
+ if entity.room != current_room:
68
+ current_room = entity.room
69
+ lines.append(f"# --- {entity.room or 'Sans pièce'} ---")
70
+
71
+ lines.append(f"{entity.entity_id}:")
72
+ lines.append(" expose: true")
73
+ lines.append(f" name: {yaml_escape(entity.name)}")
74
+
75
+ if entity.room:
76
+ lines.append(f" room: {yaml_escape(entity.room)}")
77
+
78
+ lines.append("")
79
+
80
+ content = "\n".join(lines).rstrip() + "\n"
81
+
82
+ return GenerationResult(
83
+ content=content,
84
+ entities=selection.entities,
85
+ domain_counts=dict(selection.domain_counts),
86
+ warnings=selection.warnings,
87
+ )
hvls/cli.py ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """Command-line interface for HA Voice Label Sync."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import ( # noqa: E402
11
+ FileWriterError,
12
+ GenerationRequest,
13
+ RegistryError,
14
+ WorkflowError,
15
+ run_google_assistant_workflow,
16
+ )
17
+
18
+ CONFIG_DIRECTORY = Path("/config")
19
+ STORAGE_DIRECTORY = CONFIG_DIRECTORY / ".storage"
20
+ DEFAULT_OUTPUT_FILE = CONFIG_DIRECTORY / "google_assistant_entities.yaml"
21
+
22
+ DEFAULT_DOMAINS = {
23
+ "light",
24
+ "switch",
25
+ "cover",
26
+ "climate",
27
+ "fan",
28
+ "input_boolean",
29
+ "input_select",
30
+ "scene",
31
+ "script",
32
+ "lock",
33
+ "vacuum",
34
+ "select",
35
+ }
36
+
37
+
38
+ def build_argument_parser() -> argparse.ArgumentParser:
39
+ """Build and return the command-line argument parser."""
40
+ parser = argparse.ArgumentParser(
41
+ description=(
42
+ "Generate Google Assistant entity configuration from "
43
+ "Home Assistant labels."
44
+ )
45
+ )
46
+
47
+ parser.add_argument(
48
+ "--label",
49
+ default="google_assistant",
50
+ help="Home Assistant label used to select exposed entities.",
51
+ )
52
+
53
+ parser.add_argument(
54
+ "--output",
55
+ type=Path,
56
+ default=DEFAULT_OUTPUT_FILE,
57
+ help="Path of the generated YAML file.",
58
+ )
59
+
60
+ parser.add_argument(
61
+ "--storage",
62
+ type=Path,
63
+ default=STORAGE_DIRECTORY,
64
+ help="Path to the Home Assistant .storage directory.",
65
+ )
66
+
67
+ parser.add_argument(
68
+ "--domains",
69
+ nargs="*",
70
+ default=sorted(DEFAULT_DOMAINS),
71
+ help="Home Assistant domains to include.",
72
+ )
73
+
74
+ parser.add_argument(
75
+ "--backup-directory",
76
+ type=Path,
77
+ default=None,
78
+ help=(
79
+ "Directory used for backups. "
80
+ "Defaults to .hvls_backups beside the output file."
81
+ ),
82
+ )
83
+
84
+ parser.add_argument(
85
+ "--backup-retention",
86
+ type=int,
87
+ default=5,
88
+ help="Maximum number of backups to keep.",
89
+ )
90
+
91
+ parser.add_argument(
92
+ "--dry-run",
93
+ action="store_true",
94
+ help="Display the generated YAML without writing any file.",
95
+ )
96
+
97
+ return parser
98
+
99
+
100
+ def print_summary(result: object) -> None:
101
+ """Display a human-readable workflow summary."""
102
+ generation = result.generation
103
+
104
+ print(f"Total: {generation.entity_count} entités")
105
+
106
+ if generation.domain_counts:
107
+ detail = ", ".join(
108
+ f"{domain}: {count}"
109
+ for domain, count in sorted(generation.domain_counts.items())
110
+ )
111
+ print(f"Détail par domaine -> {detail}")
112
+
113
+ for warning in generation.warnings:
114
+ print(f"Attention : {warning}", file=sys.stderr)
115
+
116
+ if result.dry_run:
117
+ print()
118
+ print(generation.content, end="")
119
+ return
120
+
121
+ write_result = result.write_result
122
+
123
+ if write_result is None:
124
+ return
125
+
126
+ print(f"OK: fichier écrit dans {write_result.output_path}")
127
+ print(f"Octets écrits: {write_result.bytes_written}")
128
+
129
+ if write_result.backup_path is not None:
130
+ print(f"Sauvegarde créée: {write_result.backup_path}")
131
+
132
+
133
+ def main() -> int:
134
+ """Run the HVLS command-line interface."""
135
+ parser = build_argument_parser()
136
+ args = parser.parse_args()
137
+
138
+ if args.backup_retention < 0:
139
+ parser.error("--backup-retention ne peut pas être négatif.")
140
+
141
+ request = GenerationRequest(
142
+ label=args.label,
143
+ domains=frozenset(args.domains),
144
+ output_path=args.output,
145
+ dry_run=args.dry_run,
146
+ )
147
+
148
+ try:
149
+ result = run_google_assistant_workflow(
150
+ request=request,
151
+ storage_path=args.storage,
152
+ backup_directory=args.backup_directory,
153
+ backup_retention=args.backup_retention,
154
+ )
155
+ except (
156
+ RegistryError,
157
+ FileWriterError,
158
+ WorkflowError,
159
+ OSError,
160
+ ValueError,
161
+ ) as exc:
162
+ print(f"Erreur : {exc}", file=sys.stderr)
163
+ return 1
164
+
165
+ print_summary(result)
166
+ return 0
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())
hvls/engine.py ADDED
@@ -0,0 +1,178 @@
1
+ """Core entity selection engine for HA Voice Label Sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from collections.abc import Collection
8
+ from typing import Any
9
+
10
+ from .models import GenerationRequest, SelectedEntity, SelectionResult
11
+ from .registry import get_registry_items
12
+
13
+
14
+ def slugify(value: str) -> str:
15
+ """Normalize a Home Assistant label for comparison."""
16
+ normalized = unicodedata.normalize("NFKD", value.strip().lower())
17
+ ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
18
+ ascii_value = ascii_value.replace(" ", "_").replace("-", "_")
19
+ ascii_value = re.sub(r"[^a-z0-9_]", "", ascii_value)
20
+ return re.sub(r"_+", "_", ascii_value).strip("_")
21
+
22
+
23
+ def resolve_label_ids(
24
+ labels: Collection[dict[str, Any]],
25
+ label_name: str,
26
+ ) -> tuple[frozenset[str], tuple[str, ...]]:
27
+ """Resolve a label name or normalized label identifier.
28
+
29
+ Args:
30
+ labels: Home Assistant label registry entries.
31
+ label_name: Label name or identifier requested by the user.
32
+
33
+ Returns:
34
+ Matching label identifiers and any generated warnings.
35
+ """
36
+ wanted_slug = slugify(label_name)
37
+ identifiers = {label_name, wanted_slug}
38
+ found = False
39
+
40
+ for label in labels:
41
+ name = str(label.get("name") or "")
42
+ label_id = str(label.get("label_id") or "")
43
+
44
+ if (
45
+ label_id == label_name
46
+ or name == label_name
47
+ or slugify(name) == wanted_slug
48
+ ):
49
+ if label_id:
50
+ identifiers.add(label_id)
51
+ found = True
52
+
53
+ warnings: tuple[str, ...] = ()
54
+
55
+ if not found:
56
+ warnings = (
57
+ f"No Home Assistant label matches '{label_name}'.",
58
+ )
59
+
60
+ return frozenset(identifiers), warnings
61
+
62
+
63
+ def select_entities(
64
+ request: GenerationRequest,
65
+ entity_registry: dict[str, Any],
66
+ device_registry: dict[str, Any],
67
+ area_registry: dict[str, Any],
68
+ label_registry: dict[str, Any],
69
+ ) -> SelectionResult:
70
+ """Select Home Assistant entities matching a generation request.
71
+
72
+ This function performs no file writing and produces no backend-specific
73
+ configuration.
74
+
75
+ Args:
76
+ request: Selection and generation parameters.
77
+ entity_registry: Parsed Home Assistant entity registry.
78
+ device_registry: Parsed Home Assistant device registry.
79
+ area_registry: Parsed Home Assistant area registry.
80
+ label_registry: Parsed Home Assistant label registry.
81
+
82
+ Returns:
83
+ A structured selection result.
84
+ """
85
+ entity_entries = get_registry_items(entity_registry, "entities")
86
+ device_entries = get_registry_items(device_registry, "devices")
87
+ area_entries = get_registry_items(area_registry, "areas")
88
+ label_entries = get_registry_items(label_registry, "labels")
89
+
90
+ label_ids, warnings = resolve_label_ids(
91
+ label_entries,
92
+ request.label,
93
+ )
94
+
95
+ devices = {
96
+ str(device["id"]): device
97
+ for device in device_entries
98
+ if device.get("id")
99
+ }
100
+
101
+ areas: dict[str, str] = {}
102
+
103
+ for area in area_entries:
104
+ area_id = area.get("area_id") or area.get("id")
105
+
106
+ if not area_id:
107
+ continue
108
+
109
+ area_id = str(area_id)
110
+ areas[area_id] = str(area.get("name") or area_id)
111
+
112
+ selected: list[SelectedEntity] = []
113
+ domain_counts: dict[str, int] = {}
114
+
115
+ for entity in entity_entries:
116
+ entity_id = entity.get("entity_id")
117
+
118
+ if not isinstance(entity_id, str) or "." not in entity_id:
119
+ continue
120
+
121
+ domain, object_id = entity_id.split(".", 1)
122
+
123
+ if domain not in request.domains:
124
+ continue
125
+
126
+ if entity.get("disabled_by") or entity.get("hidden_by"):
127
+ continue
128
+
129
+ if entity.get("entity_category") in {"diagnostic", "config"}:
130
+ continue
131
+
132
+ entity_labels = set(entity.get("labels") or [])
133
+
134
+ if not entity_labels.intersection(label_ids):
135
+ continue
136
+
137
+ device_id = entity.get("device_id")
138
+ device = devices.get(str(device_id)) if device_id else None
139
+
140
+ name = (
141
+ entity.get("name")
142
+ or entity.get("original_name")
143
+ or object_id.replace("_", " ").title()
144
+ )
145
+
146
+ area_id = entity.get("area_id") or (device or {}).get("area_id")
147
+ room = areas.get(str(area_id)) if area_id else None
148
+
149
+ selected.append(
150
+ SelectedEntity(
151
+ entity_id=entity_id,
152
+ domain=domain,
153
+ name=str(name),
154
+ room=room,
155
+ )
156
+ )
157
+
158
+ domain_counts[domain] = domain_counts.get(domain, 0) + 1
159
+
160
+ selected.sort(
161
+ key=lambda item: (
162
+ item.room or "",
163
+ item.domain,
164
+ item.name,
165
+ item.entity_id,
166
+ )
167
+ )
168
+
169
+ if not selected:
170
+ warnings += (
171
+ "No entity matched the requested label and domains.",
172
+ )
173
+
174
+ return SelectionResult(
175
+ entities=tuple(selected),
176
+ domain_counts=domain_counts,
177
+ warnings=warnings,
178
+ )
hvls/models.py ADDED
@@ -0,0 +1,90 @@
1
+ """Internal data models used by the HVLS core engine."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SelectedEntity:
10
+ """Home Assistant entity selected for a voice assistant backend."""
11
+
12
+ entity_id: str
13
+ domain: str
14
+ name: str
15
+ room: str | None = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class GenerationRequest:
20
+ """Input parameters required to generate a backend configuration."""
21
+
22
+ label: str
23
+ domains: frozenset[str]
24
+ output_path: Path
25
+ dry_run: bool = False
26
+
27
+ @dataclass(frozen=True)
28
+ class SelectionResult:
29
+ """Structured result returned by the entity selection engine."""
30
+
31
+ entities: tuple[SelectedEntity, ...]
32
+ domain_counts: dict[str, int] = field(default_factory=dict)
33
+ warnings: tuple[str, ...] = ()
34
+
35
+ @property
36
+ def entity_count(self) -> int:
37
+ """Return the number of selected entities."""
38
+ return len(self.entities)
39
+
40
+ @property
41
+ def success(self) -> bool:
42
+ """Return whether at least one entity was selected."""
43
+ return self.entity_count > 0
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class GenerationResult:
48
+ """Structured result returned by the HVLS generation engine."""
49
+
50
+ content: str
51
+ entities: tuple[SelectedEntity, ...]
52
+ domain_counts: dict[str, int] = field(default_factory=dict)
53
+ warnings: tuple[str, ...] = ()
54
+
55
+ @property
56
+ def entity_count(self) -> int:
57
+ """Return the number of selected entities."""
58
+ return len(self.entities)
59
+
60
+ @property
61
+ def success(self) -> bool:
62
+ """Return whether generation produced at least one entity."""
63
+ return self.entity_count > 0
64
+
65
+ @dataclass(frozen=True)
66
+ class FileWriteResult:
67
+ """Structured result returned after writing generated configuration."""
68
+
69
+ output_path: Path
70
+ backup_path: Path | None
71
+ bytes_written: int
72
+ written_at: datetime
73
+
74
+ @dataclass(frozen=True)
75
+ class WorkflowResult:
76
+ """Structured result returned by an HVLS workflow execution."""
77
+
78
+ generation: GenerationResult
79
+ write_result: FileWriteResult | None
80
+ dry_run: bool
81
+
82
+ @property
83
+ def entity_count(self) -> int:
84
+ """Return the number of selected entities."""
85
+ return self.generation.entity_count
86
+
87
+ @property
88
+ def written(self) -> bool:
89
+ """Return whether generated content was written to disk."""
90
+ return self.write_result is not None
hvls/orchestrator.py ADDED
@@ -0,0 +1,92 @@
1
+ """Workflow orchestration for HA Voice Label Sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .backends import render_google_assistant
8
+ from .engine import select_entities
9
+ from .models import GenerationRequest, WorkflowResult
10
+ from .registry import load_json
11
+ from .writers import write_with_backup
12
+
13
+
14
+ class WorkflowError(Exception):
15
+ """Raised when an HVLS workflow cannot be completed safely."""
16
+
17
+
18
+ def run_google_assistant_workflow(
19
+ request: GenerationRequest,
20
+ storage_path: Path,
21
+ backup_directory: Path | None = None,
22
+ backup_retention: int = 5,
23
+ ) -> WorkflowResult:
24
+ """Run the complete Google Assistant generation workflow.
25
+
26
+ Args:
27
+ request: Generation parameters.
28
+ storage_path: Home Assistant ``.storage`` directory.
29
+ backup_directory: Optional directory used for backups.
30
+ backup_retention: Maximum number of backups to retain.
31
+
32
+ Returns:
33
+ Structured workflow result.
34
+
35
+ Raises:
36
+ WorkflowError: If writing is requested but no entity matches.
37
+ """
38
+ entity_registry = load_json(
39
+ storage_path / "core.entity_registry"
40
+ )
41
+ device_registry = load_json(
42
+ storage_path / "core.device_registry"
43
+ )
44
+ area_registry = load_json(
45
+ storage_path / "core.area_registry"
46
+ )
47
+ label_registry = load_json(
48
+ storage_path / "core.label_registry"
49
+ )
50
+
51
+ selection = select_entities(
52
+ request=request,
53
+ entity_registry=entity_registry,
54
+ device_registry=device_registry,
55
+ area_registry=area_registry,
56
+ label_registry=label_registry,
57
+ )
58
+
59
+ generation = render_google_assistant(selection)
60
+
61
+ if request.dry_run:
62
+ return WorkflowResult(
63
+ generation=generation,
64
+ write_result=None,
65
+ dry_run=True,
66
+ )
67
+
68
+ # Safety rule: an incorrect label must never silently replace an existing
69
+ # configuration with an empty generated file.
70
+ if generation.entity_count == 0:
71
+ raise WorkflowError(
72
+ "Generation returned no entity; output file was not modified."
73
+ )
74
+
75
+ resolved_backup_directory = (
76
+ backup_directory
77
+ if backup_directory is not None
78
+ else request.output_path.parent / ".hvls_backups"
79
+ )
80
+
81
+ write_result = write_with_backup(
82
+ output_path=request.output_path,
83
+ content=generation.content,
84
+ backup_directory=resolved_backup_directory,
85
+ retention=backup_retention,
86
+ )
87
+
88
+ return WorkflowResult(
89
+ generation=generation,
90
+ write_result=write_result,
91
+ dry_run=False,
92
+ )
hvls/registry.py ADDED
@@ -0,0 +1,91 @@
1
+ """Home Assistant registry reading utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class RegistryError(Exception):
11
+ """Raised when a Home Assistant registry cannot be read."""
12
+
13
+
14
+ def load_json(path: Path) -> dict[str, Any]:
15
+ """Load and parse a Home Assistant JSON registry.
16
+
17
+ Args:
18
+ path: Path to the registry file.
19
+
20
+ Returns:
21
+ Parsed registry content.
22
+
23
+ Raises:
24
+ RegistryError: If the file is missing, unreadable or contains
25
+ invalid JSON.
26
+ """
27
+ if not path.exists():
28
+ raise RegistryError(f"Registry file not found: {path}")
29
+
30
+ if not path.is_file():
31
+ raise RegistryError(f"Registry path is not a file: {path}")
32
+
33
+ try:
34
+ with path.open("r", encoding="utf-8") as file:
35
+ data = json.load(file)
36
+ except json.JSONDecodeError as exc:
37
+ raise RegistryError(
38
+ f"Registry contains invalid JSON: {path} ({exc})"
39
+ ) from exc
40
+ except (OSError, UnicodeError) as exc:
41
+ raise RegistryError(
42
+ f"Unable to read registry: {path} ({exc})"
43
+ ) from exc
44
+
45
+ if not isinstance(data, dict):
46
+ raise RegistryError(
47
+ f"Registry root must be a JSON object: {path}"
48
+ )
49
+
50
+ return data
51
+
52
+
53
+ def get_registry_items(
54
+ registry: dict[str, Any],
55
+ collection_name: str,
56
+ ) -> list[dict[str, Any]]:
57
+ """Return a named collection from a Home Assistant registry.
58
+
59
+ Home Assistant registry collections are normally stored under:
60
+
61
+ data -> collection_name
62
+
63
+ Args:
64
+ registry: Parsed Home Assistant registry.
65
+ collection_name: Name of the requested collection.
66
+
67
+ Returns:
68
+ Registry entries represented as dictionaries.
69
+
70
+ Raises:
71
+ RegistryError: If the collection exists but is not a list, or if
72
+ an entry is not a JSON object.
73
+ """
74
+ data = registry.get("data", {})
75
+
76
+ if not isinstance(data, dict):
77
+ raise RegistryError("Registry 'data' field must be an object")
78
+
79
+ items = data.get(collection_name, [])
80
+
81
+ if not isinstance(items, list):
82
+ raise RegistryError(
83
+ f"Registry collection '{collection_name}' must be a list"
84
+ )
85
+
86
+ if not all(isinstance(item, dict) for item in items):
87
+ raise RegistryError(
88
+ f"Registry collection '{collection_name}' contains invalid entries"
89
+ )
90
+
91
+ return items
@@ -0,0 +1,17 @@
1
+ """Output writers provided by HVLS."""
2
+
3
+ from .filesystem import (
4
+ FileWriterError,
5
+ apply_backup_retention,
6
+ atomic_write,
7
+ create_backup,
8
+ write_with_backup,
9
+ )
10
+
11
+ __all__ = [
12
+ "FileWriterError",
13
+ "apply_backup_retention",
14
+ "atomic_write",
15
+ "create_backup",
16
+ "write_with_backup",
17
+ ]
@@ -0,0 +1,183 @@
1
+ """Safe filesystem writer for generated HVLS configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from ..models import FileWriteResult
12
+
13
+
14
+ class FileWriterError(Exception):
15
+ """Raised when generated configuration cannot be written safely."""
16
+
17
+
18
+ def create_backup(
19
+ output_path: Path,
20
+ backup_directory: Path,
21
+ ) -> Path | None:
22
+ """Create a timestamped backup of an existing output file.
23
+
24
+ Args:
25
+ output_path: Generated configuration file to back up.
26
+ backup_directory: Directory where backups are stored.
27
+
28
+ Returns:
29
+ Path of the created backup, or ``None`` if the output file does
30
+ not exist yet.
31
+
32
+ Raises:
33
+ FileWriterError: If the backup cannot be created.
34
+ """
35
+ if not output_path.exists():
36
+ return None
37
+
38
+ if not output_path.is_file():
39
+ raise FileWriterError(
40
+ f"Output path is not a regular file: {output_path}"
41
+ )
42
+
43
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
44
+ backup_directory.mkdir(parents=True, exist_ok=True)
45
+
46
+ backup_path = backup_directory / (
47
+ f"{output_path.name}.{timestamp}.bak"
48
+ )
49
+
50
+ try:
51
+ shutil.copy2(output_path, backup_path)
52
+ except OSError as exc:
53
+ raise FileWriterError(
54
+ f"Unable to create backup {backup_path}: {exc}"
55
+ ) from exc
56
+
57
+ return backup_path
58
+
59
+
60
+ def apply_backup_retention(
61
+ output_path: Path,
62
+ backup_directory: Path,
63
+ retention: int,
64
+ ) -> None:
65
+ """Keep only the newest backups for an output file.
66
+
67
+ A retention value of zero removes all matching backups.
68
+ """
69
+ if retention < 0:
70
+ raise ValueError("Backup retention cannot be negative")
71
+
72
+ if not backup_directory.exists():
73
+ return
74
+
75
+ pattern = f"{output_path.name}.*.bak"
76
+
77
+ try:
78
+ backups = sorted(
79
+ backup_directory.glob(pattern),
80
+ key=lambda path: path.stat().st_mtime,
81
+ reverse=True,
82
+ )
83
+
84
+ for backup in backups[retention:]:
85
+ backup.unlink()
86
+ except OSError as exc:
87
+ raise FileWriterError(
88
+ f"Unable to apply backup retention: {exc}"
89
+ ) from exc
90
+
91
+
92
+ def atomic_write(
93
+ output_path: Path,
94
+ content: str,
95
+ ) -> int:
96
+ """Write text using a temporary file and atomic replacement.
97
+
98
+ The temporary file is created in the destination directory so that
99
+ the final ``os.replace`` operation remains atomic.
100
+
101
+ Args:
102
+ output_path: Destination file.
103
+ content: UTF-8 text to write.
104
+
105
+ Returns:
106
+ Number of UTF-8 bytes written.
107
+
108
+ Raises:
109
+ FileWriterError: If the destination cannot be written safely.
110
+ """
111
+ output_path.parent.mkdir(parents=True, exist_ok=True)
112
+
113
+ encoded_content = content.encode("utf-8")
114
+ temporary_path: Path | None = None
115
+
116
+ try:
117
+ with tempfile.NamedTemporaryFile(
118
+ mode="wb",
119
+ dir=output_path.parent,
120
+ prefix=f".{output_path.name}.",
121
+ suffix=".tmp",
122
+ delete=False,
123
+ ) as temporary_file:
124
+ temporary_path = Path(temporary_file.name)
125
+ temporary_file.write(encoded_content)
126
+ temporary_file.flush()
127
+ os.fsync(temporary_file.fileno())
128
+
129
+ os.replace(temporary_path, output_path)
130
+
131
+ except OSError as exc:
132
+ if temporary_path is not None:
133
+ try:
134
+ temporary_path.unlink(missing_ok=True)
135
+ except OSError:
136
+ pass
137
+
138
+ raise FileWriterError(
139
+ f"Unable to write {output_path}: {exc}"
140
+ ) from exc
141
+
142
+ return len(encoded_content)
143
+
144
+
145
+ def write_with_backup(
146
+ output_path: Path,
147
+ content: str,
148
+ backup_directory: Path,
149
+ retention: int = 5,
150
+ ) -> FileWriteResult:
151
+ """Back up the current file and atomically write new content.
152
+
153
+ Args:
154
+ output_path: Destination configuration file.
155
+ content: Generated configuration.
156
+ backup_directory: Backup storage directory.
157
+ retention: Maximum number of backups to retain.
158
+
159
+ Returns:
160
+ Structured information about the completed operation.
161
+ """
162
+ backup_path = create_backup(
163
+ output_path=output_path,
164
+ backup_directory=backup_directory,
165
+ )
166
+
167
+ bytes_written = atomic_write(
168
+ output_path=output_path,
169
+ content=content,
170
+ )
171
+
172
+ apply_backup_retention(
173
+ output_path=output_path,
174
+ backup_directory=backup_directory,
175
+ retention=retention,
176
+ )
177
+
178
+ return FileWriteResult(
179
+ output_path=output_path,
180
+ backup_path=backup_path,
181
+ bytes_written=bytes_written,
182
+ written_at=datetime.now(),
183
+ )