ha-deploy-kit 0.1.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,9 @@
1
+ """ha_deploy_kit: HA-free SSH deploy / compare / notify toolkit.
2
+
3
+ The core package never imports Home Assistant. The HA-side adapter lives in
4
+ ``ha_deploy_kit.ha`` and must be imported explicitly.
5
+ """
6
+
7
+ from ha_deploy_kit.settings import DeployError, DeploySettings, FrontendAsset
8
+
9
+ __all__ = ["DeployError", "DeploySettings", "FrontendAsset"]
ha_deploy_kit/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ """CLI orchestration and sanitized reporting.
2
+
3
+ ``run`` is the testable entrypoint: resolve env, build the local inventory, probe
4
+ the remote, compare, report, and — only when the backend changed — deploy the
5
+ changed subset and attempt the restart notification. A frontend-only change does
6
+ no backend deploy/mkdir/notify (the frontend profile handles the card). The
7
+ engine never restarts; it only ever prints a manual-restart line as guidance.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ from collections.abc import Mapping
15
+ from dataclasses import replace
16
+ from pathlib import Path
17
+
18
+ from ha_deploy_kit.comparison import ComparisonPlan, compare, fetch_remote_inventory
19
+ from ha_deploy_kit.config import api_base, env_value, resolve_env
20
+ from ha_deploy_kit.frontend import frontend_run
21
+ from ha_deploy_kit.inventory import local_inventory
22
+ from ha_deploy_kit.notify import raise_restart_issue
23
+ from ha_deploy_kit.settings import DeployError, DeploySettings
24
+ from ha_deploy_kit.transport import SSHTransport, SubprocessSSH, deploy_backend
25
+
26
+ _STATUS_MESSAGES = {
27
+ "raised": "Restart notification raised.",
28
+ "no-token": "No API token configured; notification skipped.",
29
+ "no-service": "Restart service not registered on Home Assistant.",
30
+ "tls-error": "Could not reach Home Assistant: TLS verification failed.",
31
+ "connection-error": "Could not reach Home Assistant: connection failed.",
32
+ "invalid-config": "Invalid Home Assistant URL configuration.",
33
+ "protocol-error": "Unexpected protocol error contacting Home Assistant.",
34
+ }
35
+
36
+
37
+ def parse_args(argv: list[str]) -> argparse.Namespace:
38
+ parser = argparse.ArgumentParser(prog="ha-deploy")
39
+ parser.add_argument("--check", action="store_true", dest="check")
40
+ parser.add_argument("--dry-run", action="store_true", dest="check")
41
+ parser.add_argument("--root", default=None)
42
+ return parser.parse_args(argv)
43
+
44
+
45
+ def _notify_message(status: str, target: str) -> str:
46
+ base = _STATUS_MESSAGES.get(status)
47
+ if base is None:
48
+ base = f"Home Assistant returned {status}."
49
+ manual = f"Restart manually: ssh {target} 'ha core restart'"
50
+ return f"{base} {manual}"
51
+
52
+
53
+ def report(settings: DeploySettings, plan: ComparisonPlan, target: str) -> None:
54
+ print(f"Target: {target} (domain: {settings.domain})")
55
+ if plan.in_sync:
56
+ print("Backend: in sync.")
57
+ elif plan.changed_backend:
58
+ print(f"Backend: {len(plan.changed_backend)} file(s) to deploy.")
59
+ else:
60
+ print("Backend: no changes.")
61
+ if plan.stale_remote:
62
+ print(f"Remote-only files (left untouched): {len(plan.stale_remote)}.")
63
+ if settings.frontend is not None:
64
+ if plan.frontend_changed:
65
+ print("Frontend: card changes pending.")
66
+ else:
67
+ print("Frontend: in sync.")
68
+
69
+
70
+ def run(
71
+ settings: DeploySettings,
72
+ argv: list[str],
73
+ ssh: SSHTransport | None = None,
74
+ environ: Mapping[str, str] | None = None,
75
+ ) -> int:
76
+ args = parse_args(argv)
77
+ environ = os.environ if environ is None else environ
78
+
79
+ resolved = resolve_env(settings.source_root / ".env", environ)
80
+ base = api_base(resolved)
81
+ token = env_value(resolved, "HA_TOKEN")
82
+ target = env_value(resolved, "TARGET")
83
+
84
+ remote_config = env_value(resolved, "REMOTE_CONFIG")
85
+ if remote_config:
86
+ settings = replace(settings, remote_config=remote_config)
87
+
88
+ if ssh is None:
89
+ ssh = SubprocessSSH(target, settings.ssh_timeout)
90
+
91
+ local = local_inventory(settings)
92
+ rel_paths = sorted(local)
93
+ remote = fetch_remote_inventory(ssh, settings, rel_paths)
94
+ plan = compare(local, remote, settings)
95
+ report(settings, plan, target)
96
+
97
+ if plan.in_sync:
98
+ print("Already in sync; nothing to do.")
99
+ return 0
100
+
101
+ # Publish the card (or preview on --check). No-op when frontend is None or
102
+ # unchanged; a frontend-only change publishes but never notifies a restart.
103
+ for note in frontend_run(settings, plan, ssh, check=args.check):
104
+ print(note)
105
+
106
+ if args.check:
107
+ print("Check only: no changes applied.")
108
+ return 0
109
+
110
+ if plan.changed_backend:
111
+ try:
112
+ deploy_backend(ssh, settings, plan.changed_backend)
113
+ except DeployError as exc:
114
+ print(f"Deployment failed: {exc}")
115
+ return 1
116
+ status = raise_restart_issue(base, token, settings)
117
+ print(_notify_message(status, target))
118
+
119
+ return 0
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ args = parse_args(argv)
124
+ root = args.root or os.environ.get("HA_DEPLOY_SOURCE_ROOT") or os.getcwd()
125
+ source_root = Path(root)
126
+
127
+ domain = os.environ.get("HA_DEPLOY_DOMAIN")
128
+ if not domain:
129
+ components = source_root / "custom_components"
130
+ candidates = (
131
+ [p.name for p in sorted(components.iterdir()) if p.is_dir()]
132
+ if components.is_dir()
133
+ else []
134
+ )
135
+ if len(candidates) != 1:
136
+ print(
137
+ "Set HA_DEPLOY_DOMAIN: could not infer a single component under "
138
+ f"{components}"
139
+ )
140
+ return 2
141
+ domain = candidates[0]
142
+
143
+ settings = DeploySettings(domain=domain, source_root=source_root)
144
+ return run(settings, argv or [])
@@ -0,0 +1,202 @@
1
+ """Remote inventory wire format and comparison planning (pure).
2
+
3
+ The remote probe is a single one-shot shell script whose output is parsed into a
4
+ structured ``RemoteInventory`` with an explicit ``missing_tree`` flag. ``compare``
5
+ diffs that against the local inventory and produces a ``ComparisonPlan``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ha_deploy_kit.settings import DeployError, DeploySettings
14
+
15
+ if TYPE_CHECKING:
16
+ from ha_deploy_kit.transport import SSHTransport
17
+
18
+
19
+ def _shquote(value: str) -> str:
20
+ """Single-quote a value for safe embedding in a POSIX shell script."""
21
+ return "'" + value.replace("'", "'\\''") + "'"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class RemoteInventory:
26
+ """Parsed result of the remote probe.
27
+
28
+ ``missing_tree`` is explicit (corrects the reference 3-vs-4 return mismatch):
29
+ an absent component dir is a distinct state from an empty hash map.
30
+ """
31
+
32
+ hashes: dict[str, str]
33
+ stale: list[str] = field(default_factory=list)
34
+ missing_tree: bool = False
35
+ published_hash: str | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ComparisonPlan:
40
+ changed_backend: list[str]
41
+ stale_remote: list[str]
42
+ frontend_tree_changed: bool = False
43
+ frontend_published_changed: bool = False
44
+
45
+ @property
46
+ def frontend_changed(self) -> bool:
47
+ return self.frontend_tree_changed or self.frontend_published_changed
48
+
49
+ @property
50
+ def in_sync(self) -> bool:
51
+ return not self.changed_backend and not self.frontend_changed
52
+
53
+
54
+ def build_inventory_script(settings: DeploySettings, rel_paths: list[str]) -> str:
55
+ """Build the one-shot remote probe script.
56
+
57
+ Reads requested relative paths from stdin; emits ``sha256sum``/``MISSING <f>``
58
+ per requested path, ``REMOTE_ONLY <p>`` for every remote file (caches
59
+ excluded), ``NO_REMOTE_TREE`` when the component dir is absent, and
60
+ ``WWW:card``/``MISSING WWW:card`` for a configured frontend published copy.
61
+ """
62
+ remote = _shquote(settings.remote_component)
63
+ parts = [
64
+ "set -u",
65
+ f"DIR={remote}",
66
+ 'if [ ! -d "$DIR" ]; then',
67
+ " echo NO_REMOTE_TREE",
68
+ "else",
69
+ " while IFS= read -r f; do",
70
+ ' [ -z "$f" ] && continue',
71
+ ' if [ -f "$DIR/$f" ]; then',
72
+ ' (cd "$DIR" && sha256sum "$f")',
73
+ " else",
74
+ ' echo "MISSING $f"',
75
+ " fi",
76
+ " done",
77
+ (
78
+ ' find "$DIR" -type f ! -path "*/__pycache__/*" '
79
+ '! -name "*.pyc" 2>/dev/null \\'
80
+ ),
81
+ ' | sed "s#^$DIR/##" | sort | while IFS= read -r p; do',
82
+ ' echo "REMOTE_ONLY $p"',
83
+ " done",
84
+ "fi",
85
+ ]
86
+ if settings.frontend is not None:
87
+ published = (
88
+ settings.remote_config.rstrip("/")
89
+ + "/"
90
+ + settings.frontend.published_remote
91
+ )
92
+ card = _shquote(published)
93
+ parts += [
94
+ f"CARD={card}",
95
+ 'if [ -f "$CARD" ]; then',
96
+ ' echo "$(sha256sum "$CARD" | cut -d\' \' -f1) WWW:card"',
97
+ "else",
98
+ " echo MISSING WWW:card",
99
+ "fi",
100
+ ]
101
+ return "\n".join(parts) + "\n"
102
+
103
+
104
+ def parse_inventory(
105
+ output: str, rel_paths: list[str], settings: DeploySettings
106
+ ) -> RemoteInventory:
107
+ """Parse the remote probe output into a ``RemoteInventory``.
108
+
109
+ ``REMOTE_ONLY`` entries that correspond to a requested (local) path are not
110
+ stale. Raises ``DeployError`` on empty output.
111
+ """
112
+ if not output.strip():
113
+ raise DeployError("empty remote inventory output")
114
+
115
+ requested = set(rel_paths)
116
+ hashes: dict[str, str] = {}
117
+ stale: list[str] = []
118
+ missing_tree = False
119
+ published_hash: str | None = None
120
+
121
+ for raw in output.splitlines():
122
+ line = raw.strip()
123
+ if not line:
124
+ continue
125
+ if line == "NO_REMOTE_TREE":
126
+ missing_tree = True
127
+ elif line == "MISSING WWW:card":
128
+ published_hash = None
129
+ elif line.endswith("WWW:card"):
130
+ published_hash = line.split()[0]
131
+ elif line.startswith("MISSING "):
132
+ hashes[line[len("MISSING ") :]] = ""
133
+ elif line.startswith("REMOTE_ONLY "):
134
+ path = line[len("REMOTE_ONLY ") :]
135
+ if path not in requested:
136
+ stale.append(path)
137
+ else:
138
+ parts = line.split()
139
+ if len(parts) >= 2 and len(parts[0]) == 64:
140
+ hashes[parts[-1]] = parts[0]
141
+ # anything else is noise and ignored
142
+
143
+ return RemoteInventory(
144
+ hashes=hashes,
145
+ stale=stale,
146
+ missing_tree=missing_tree,
147
+ published_hash=published_hash,
148
+ )
149
+
150
+
151
+ def fetch_remote_inventory(
152
+ ssh: SSHTransport, settings: DeploySettings, rel_paths: list[str]
153
+ ) -> RemoteInventory:
154
+ """Bridge transport to pure parsing: send the probe, parse the result."""
155
+ script = build_inventory_script(settings, rel_paths)
156
+ stdin = ("\n".join(rel_paths) + "\n").encode()
157
+ output = ssh(script, stdin=stdin)
158
+ return parse_inventory(output.decode(), rel_paths, settings)
159
+
160
+
161
+ def compare(
162
+ local: dict[str, str], remote: RemoteInventory, settings: DeploySettings
163
+ ) -> ComparisonPlan:
164
+ """Diff local vs remote into a ``ComparisonPlan``.
165
+
166
+ The frontend ``source_rel`` is excluded from ``changed_backend`` and tracked
167
+ separately. A configured-but-missing local card raises ``DeployError`` before
168
+ any write; an absent optional asset never raises.
169
+ """
170
+ frontend = settings.frontend
171
+ source_rel = frontend.source_rel if frontend is not None else None
172
+
173
+ if source_rel is not None and source_rel not in local:
174
+ raise DeployError(
175
+ f"configured frontend source '{source_rel}' not found in local tree"
176
+ )
177
+
178
+ backend_local = {k: v for k, v in local.items() if k != source_rel}
179
+
180
+ if remote.missing_tree:
181
+ changed_backend = sorted(backend_local)
182
+ else:
183
+ changed_backend = [
184
+ k for k in sorted(backend_local) if remote.hashes.get(k) != backend_local[k]
185
+ ]
186
+
187
+ frontend_tree_changed = False
188
+ frontend_published_changed = False
189
+ if source_rel is not None:
190
+ local_card = local[source_rel]
191
+ if remote.missing_tree:
192
+ frontend_tree_changed = True
193
+ else:
194
+ frontend_tree_changed = remote.hashes.get(source_rel) != local_card
195
+ frontend_published_changed = remote.published_hash != local_card
196
+
197
+ return ComparisonPlan(
198
+ changed_backend=changed_backend,
199
+ stale_remote=list(remote.stale),
200
+ frontend_tree_changed=frontend_tree_changed,
201
+ frontend_published_changed=frontend_published_changed,
202
+ )
@@ -0,0 +1,63 @@
1
+ """Configuration loading without global mutation.
2
+
3
+ ``load_dotenv`` returns the parsed values and never touches ``os.environ`` (the
4
+ reference scripts mutated it, which leaks between two consumers in one process).
5
+ ``resolve_env`` merges with process env taking precedence over the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+
14
+ _KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
15
+ _API_BASE_KEY = "HA_BASE_URL"
16
+
17
+
18
+ def load_dotenv(path: Path) -> dict[str, str]:
19
+ """Parse a ``KEY=VALUE`` file into a dict without mutating ``os.environ``.
20
+
21
+ Ignores blanks and ``#`` comments, strips a leading ``export``, strips one
22
+ layer of matching single/double quotes, keeps empty values, accepts only
23
+ ``[A-Za-z_][A-Za-z0-9_]*`` keys, ignores malformed lines, and performs no
24
+ shell evaluation. A missing file yields ``{}``.
25
+ """
26
+ if not path.is_file():
27
+ return {}
28
+
29
+ result: dict[str, str] = {}
30
+ for raw in path.read_text().splitlines():
31
+ line = raw.strip()
32
+ if not line or line.startswith("#"):
33
+ continue
34
+ if line.startswith("export "):
35
+ line = line[len("export ") :].lstrip()
36
+ if "=" not in line:
37
+ continue
38
+ key, _, value = line.partition("=")
39
+ key = key.strip()
40
+ if not _KEY_RE.fullmatch(key):
41
+ continue
42
+ value = value.strip()
43
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
44
+ value = value[1:-1]
45
+ result[key] = value
46
+ return result
47
+
48
+
49
+ def resolve_env(path: Path, environ: Mapping[str, str]) -> dict[str, str]:
50
+ """Merge file values with process env, where process env wins."""
51
+ resolved = load_dotenv(path)
52
+ resolved.update(environ)
53
+ return resolved
54
+
55
+
56
+ def env_value(resolved: Mapping[str, str], key: str) -> str:
57
+ """Return a stripped value for ``key`` (empty string when absent)."""
58
+ return resolved.get(key, "").strip()
59
+
60
+
61
+ def api_base(resolved: Mapping[str, str]) -> str:
62
+ """Return the HA base URL, stripped and without a trailing slash."""
63
+ return env_value(resolved, _API_BASE_KEY).rstrip("/")
@@ -0,0 +1,87 @@
1
+ """Optional frontend profile: publish a served card copy and run frontend steps.
2
+
3
+ Frontend copying never triggers a backend restart notification and never adds a
4
+ JS build system. It only streams the card file via the same validated tar path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import posixpath
10
+ import tarfile
11
+ from io import BytesIO
12
+
13
+ from ha_deploy_kit.comparison import ComparisonPlan
14
+ from ha_deploy_kit.settings import DeploySettings
15
+ from ha_deploy_kit.transport import (
16
+ SSHTransport,
17
+ _shquote,
18
+ deploy_backend,
19
+ validate_members,
20
+ )
21
+
22
+
23
+ def publish_frontend(ssh: SSHTransport, settings: DeploySettings) -> None:
24
+ """Stream the card to its separately-served published location."""
25
+ frontend = settings.frontend
26
+ if frontend is None:
27
+ return
28
+
29
+ validate_members(settings, [frontend.source_rel])
30
+ published = settings.remote_config.rstrip("/") + "/" + frontend.published_remote
31
+ published_dir = posixpath.dirname(published)
32
+ arcname = posixpath.basename(published)
33
+
34
+ def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
35
+ info.uid = 0
36
+ info.gid = 0
37
+ info.uname = ""
38
+ info.gname = ""
39
+ return info
40
+
41
+ buffer = BytesIO()
42
+ with tarfile.open(fileobj=buffer, mode="w") as tar:
43
+ tar.add(
44
+ settings.component_dir / frontend.source_rel,
45
+ arcname=arcname,
46
+ recursive=False,
47
+ filter=_normalize,
48
+ )
49
+
50
+ ssh("mkdir -p " + _shquote(published_dir))
51
+ ssh(f"tar -xf - -C {_shquote(published_dir)}", stdin=buffer.getvalue())
52
+
53
+
54
+ def frontend_run(
55
+ settings: DeploySettings,
56
+ plan: ComparisonPlan,
57
+ ssh: SSHTransport,
58
+ *,
59
+ check: bool,
60
+ ) -> list[str]:
61
+ """Deploy the tree card and/or published copy per the plan.
62
+
63
+ No restart side effects.
64
+ """
65
+ notes: list[str] = []
66
+ frontend = settings.frontend
67
+ if frontend is None or not plan.frontend_changed:
68
+ return notes
69
+
70
+ if check:
71
+ if plan.frontend_tree_changed:
72
+ notes.append("Would deploy the card into the component tree.")
73
+ if plan.frontend_published_changed:
74
+ notes.append("Would publish the served card copy.")
75
+ return notes
76
+
77
+ if plan.frontend_tree_changed:
78
+ deploy_backend(ssh, settings, [frontend.source_rel])
79
+ notes.append("Deployed the card into the component tree.")
80
+ if plan.frontend_published_changed:
81
+ publish_frontend(ssh, settings)
82
+ notes.append(
83
+ "Published the served card copy; hard-refresh the browser "
84
+ "(Ctrl/Cmd+Shift+R) to pick it up."
85
+ )
86
+
87
+ return notes
@@ -0,0 +1,20 @@
1
+ """Home Assistant adapter for ha_deploy_kit.
2
+
3
+ Importing this subpackage imports Home Assistant; the core never does.
4
+ """
5
+
6
+ from ha_deploy_kit.ha.repairs_flow import async_create_restart_fix_flow
7
+ from ha_deploy_kit.ha.repairs_issue import (
8
+ async_clear_restart_issue,
9
+ async_raise_restart_issue,
10
+ )
11
+ from ha_deploy_kit.ha.service import async_register_restart_service
12
+ from ha_deploy_kit.ha.serving import register_card_serving
13
+
14
+ __all__ = [
15
+ "async_clear_restart_issue",
16
+ "async_create_restart_fix_flow",
17
+ "async_raise_restart_issue",
18
+ "async_register_restart_service",
19
+ "register_card_serving",
20
+ ]
@@ -0,0 +1,48 @@
1
+ """Optional opt-in confirm-and-restart Repairs flow.
2
+
3
+ The restart happens ONLY on an explicit form submit. No executable restart lives
4
+ in the core engine; this flow is the single opt-in place a consumer may wire it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import voluptuous as vol
12
+ from homeassistant.components.repairs import RepairsFlow
13
+
14
+ if TYPE_CHECKING:
15
+ from homeassistant.core import HomeAssistant
16
+ from homeassistant.data_entry_flow import FlowResult
17
+
18
+
19
+ class _RestartRequiredFixFlow(RepairsFlow):
20
+ """Shows a confirmation form, then restarts only when the user submits it."""
21
+
22
+ async def async_step_init(
23
+ self, user_input: dict[str, Any] | None = None
24
+ ) -> FlowResult:
25
+ return await self.async_step_confirm_restart(user_input)
26
+
27
+ async def async_step_confirm_restart(
28
+ self, user_input: dict[str, Any] | None = None
29
+ ) -> FlowResult:
30
+ if user_input is None:
31
+ return self.async_show_form(
32
+ step_id="confirm_restart", data_schema=vol.Schema({})
33
+ )
34
+ await self.hass.services.async_call("homeassistant", "restart", blocking=False)
35
+ return self.async_create_entry(title="", data={})
36
+
37
+
38
+ def async_create_restart_fix_flow(
39
+ hass: HomeAssistant,
40
+ issue_id: str,
41
+ data: dict[str, Any] | None = None,
42
+ *,
43
+ expected_issue_id: str = "restart_required",
44
+ ) -> RepairsFlow | None:
45
+ """Return the opt-in restart fix flow, or ``None`` for other issue ids."""
46
+ if issue_id != expected_issue_id:
47
+ return None
48
+ return _RestartRequiredFixFlow()
@@ -0,0 +1,46 @@
1
+ """Repairs issue helpers for the restart-required notification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from homeassistant.helpers import issue_registry as ir
8
+
9
+ if TYPE_CHECKING:
10
+ from homeassistant.core import HomeAssistant
11
+
12
+
13
+ def async_raise_restart_issue(
14
+ hass: HomeAssistant,
15
+ domain: str,
16
+ issue_id: str = "restart_required",
17
+ *,
18
+ translation_key: str = "restart_required",
19
+ is_fixable: bool = False,
20
+ is_persistent: bool = False,
21
+ learn_more_url: str | None = None,
22
+ ) -> None:
23
+ """Create a deduplicated restart issue scoped to ``domain``+``issue_id``.
24
+
25
+ Defaults to a non-persistent, notification-only warning. Keyed by
26
+ ``(domain, issue_id)``, so it never overwrites another domain's issue.
27
+ """
28
+ ir.async_create_issue(
29
+ hass,
30
+ domain,
31
+ issue_id,
32
+ is_fixable=is_fixable,
33
+ is_persistent=is_persistent,
34
+ severity=ir.IssueSeverity.WARNING,
35
+ translation_key=translation_key,
36
+ learn_more_url=learn_more_url,
37
+ )
38
+
39
+
40
+ def async_clear_restart_issue(
41
+ hass: HomeAssistant,
42
+ domain: str,
43
+ issue_id: str = "restart_required",
44
+ ) -> None:
45
+ """Remove the restart issue for ``domain``+``issue_id`` if present."""
46
+ ir.async_delete_issue(hass, domain, issue_id)
@@ -0,0 +1,52 @@
1
+ """Home Assistant service registration for the restart-required issue.
2
+
3
+ Imports Home Assistant; must be imported explicitly as ``ha_deploy_kit.ha``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ import voluptuous as vol
11
+
12
+ from ha_deploy_kit.ha.repairs_issue import async_raise_restart_issue
13
+
14
+ if TYPE_CHECKING:
15
+ from homeassistant.core import HomeAssistant, ServiceCall
16
+
17
+
18
+ def async_register_restart_service(
19
+ hass: HomeAssistant,
20
+ domain: str,
21
+ *,
22
+ service_name: str = "raise_restart_issue",
23
+ issue_id: str = "restart_required",
24
+ translation_key: str = "restart_required",
25
+ learn_more_url: str | None = None,
26
+ persistent: bool = False,
27
+ fixable: bool = False,
28
+ ) -> None:
29
+ """Idempotently register the service that raises the restart issue.
30
+
31
+ Registers with an empty ``vol.Schema({})`` (rejects any payload) and works
32
+ from global ``async_setup`` before any config entry loads.
33
+
34
+ Set ``fixable=True`` to raise a one-click (fixable) Repairs issue; Home
35
+ Assistant only runs a fix flow for a fixable issue, so pair this with a
36
+ wired ``async_create_restart_fix_flow``. The default stays notification-only.
37
+ """
38
+ if hass.services.has_service(domain, service_name):
39
+ return
40
+
41
+ async def _handle(call: ServiceCall) -> None:
42
+ async_raise_restart_issue(
43
+ hass,
44
+ domain,
45
+ issue_id,
46
+ translation_key=translation_key,
47
+ is_fixable=fixable,
48
+ is_persistent=persistent,
49
+ learn_more_url=learn_more_url,
50
+ )
51
+
52
+ hass.services.async_register(domain, service_name, _handle, schema=vol.Schema({}))