zigporter 0.1.0__tar.gz

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,158 @@
1
+ Metadata-Version: 2.3
2
+ Name: zigporter
3
+ Version: 0.1.0
4
+ Summary: CLI tool to migrate Zigbee devices from ZHA to Zigbee2MQTT in Home Assistant
5
+ Author: Even Nordstad
6
+ Author-email: Even Nordstad <even.nordstad@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: typer>=0.12
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: websockets>=12
11
+ Requires-Dist: pydantic>=2.7
12
+ Requires-Dist: python-dotenv>=1.0
13
+ Requires-Dist: rich>=13
14
+ Requires-Dist: questionary>=2.1.1
15
+ Requires-Dist: platformdirs>=4.0
16
+ Requires-Python: >=3.12
17
+ Project-URL: Homepage, https://github.com/nordstad/zigporter
18
+ Project-URL: Repository, https://github.com/nordstad/zigporter
19
+ Project-URL: Issues, https://github.com/nordstad/zigporter/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ [![CI](https://github.com/nordstad/zigporter/actions/workflows/ci.yml/badge.svg)](https://github.com/nordstad/zigporter/actions/workflows/ci.yml)
23
+ [![codecov](https://codecov.io/gh/nordstad/zigporter/graph/badge.svg)](https://codecov.io/gh/nordstad/zigporter)
24
+ [![Documentation](https://img.shields.io/badge/docs-zensical-blue)](https://nordstad.github.io/zigporter)
25
+ [![PyPI - Version](https://img.shields.io/pypi/v/zigporter)](https://pypi.org/project/zigporter/)
26
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/zigporter)](https://pepy.tech/project/zigporter)
27
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
29
+
30
+ # zigporter
31
+
32
+ *Because migrating 30 Zigbee devices in Home Assistant by hand is a special kind of misery.*
33
+
34
+ CLI tool that automates the ZHA → Zigbee2MQTT migration in Home Assistant — one device at a
35
+ time, with checkpoints so you can stop and pick up where you left off.
36
+
37
+ > **Early Development Notice**
38
+ > This tool is in early development and has only been tested with one specific setup:
39
+ > - Home Assistant OS 2026.2.3
40
+ > - Supervisor 2026.02.2
41
+ > - Zigbee2MQTT 2.8.0-1
42
+ >
43
+ > I have not had the possibility to test with different HA or Z2M versions and setups.
44
+ > Feedback is very welcome — please open an [issue](https://github.com/nordstad/zigporter/issues) or submit a [PR](https://github.com/nordstad/zigporter/pulls) if you test with a different configuration.
45
+
46
+ > **Early Development Notice**
47
+ > This tool is in early development and has only been tested with one specific setup:
48
+ > - Home Assistant OS 2026.2.3
49
+ > - Supervisor 2026.02.2
50
+ > - Zigbee2MQTT 2.8.0-1
51
+ >
52
+ > I have not had the possibility to test with different HA or Z2M versions and setups.
53
+ > Feedback is very welcome — please open an [issue](https://github.com/nordstad/zigporter/issues) or submit a [PR](https://github.com/nordstad/zigporter/pulls) if you test with a different configuration.
54
+
55
+ ## Requirements
56
+
57
+ - Python 3.12+
58
+ - [uv](https://docs.astral.sh/uv/)
59
+ - Home Assistant with ZHA and Zigbee2MQTT add-on
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ uv tool install zigporter
65
+ ```
66
+
67
+ ## Configuration
68
+
69
+ **Option 1 — Setup wizard (recommended)**
70
+
71
+ ```bash
72
+ zigporter setup
73
+ ```
74
+
75
+ Prompts for all values and saves to `~/.config/zigporter/.env`.
76
+
77
+ **Option 2 — Manual config file**
78
+
79
+ Create `~/.config/zigporter/.env` (see `.env.example` for the template):
80
+
81
+ ```bash
82
+ mkdir -p ~/.config/zigporter
83
+ cp .env.example ~/.config/zigporter/.env
84
+ # edit the file with your values
85
+ ```
86
+
87
+ **Option 3 — Environment variables**
88
+
89
+ Export directly in your shell or add to `~/.zshenv` / `~/.bashrc`:
90
+
91
+ ```bash
92
+ export HA_URL=https://your-ha-instance.local
93
+ export HA_TOKEN=your_token
94
+ export Z2M_URL=https://your-ha-instance.local/abc123_zigbee2mqtt
95
+ ```
96
+
97
+ | Variable | Required | Description |
98
+ |---|---|---|
99
+ | `HA_URL` | Yes | Home Assistant URL |
100
+ | `HA_TOKEN` | Yes | [Long-Lived Access Token](https://www.home-assistant.io/docs/authentication/#your-account-profile) |
101
+ | `HA_VERIFY_SSL` | No | `true` / `false` (default: `true`; use `false` for self-signed certs) |
102
+ | `Z2M_URL` | Yes | Zigbee2MQTT ingress URL |
103
+ | `Z2M_MQTT_TOPIC` | No | Z2M base topic (default: `zigbee2mqtt`) |
104
+
105
+ ## Usage
106
+
107
+ ```bash
108
+ # Verify your setup before migrating (recommended first step)
109
+ zigporter check
110
+
111
+ # Run the migration wizard (runs checks automatically on first run)
112
+ zigporter migrate
113
+
114
+ # Check migration progress without entering the wizard
115
+ zigporter migrate --status
116
+
117
+ # (Optional) manually export your ZHA device inventory
118
+ zigporter export
119
+
120
+ # (Optional) inspect what's already in Z2M
121
+ zigporter list-z2m
122
+ ```
123
+
124
+ `zigporter migrate` handles everything automatically on first run:
125
+ 1. Runs pre-flight checks (HA reachable, ZHA active, Z2M running)
126
+ 2. Prompts you to back up Home Assistant and your ZHA network
127
+ 3. Fetches a ZHA export if one is not found, or offers to refresh an existing one
128
+ 4. Opens the interactive migration wizard
129
+
130
+ All files are stored in `~/.config/zigporter/` so the tool works from any directory.
131
+ Use `--skip-checks` on subsequent runs to skip the pre-flight checks.
132
+
133
+ ## How it works
134
+
135
+ The wizard migrates one device at a time through five steps:
136
+
137
+ 1. **Remove from ZHA** — confirms deletion in the HA registry
138
+ 2. **Reset device** — prompts you to factory-reset the physical device
139
+ 3. **Pair with Z2M** — opens a 120 s permit-join window and polls by IEEE address
140
+ 4. **Rename** — applies the original ZHA name and area in Z2M and HA
141
+ 5. **Validate** — polls HA entity states until all are online
142
+
143
+ State is written to `zha-migration-state.json` after every step. `Ctrl-C` marks the device `FAILED` — rerun to retry.
144
+
145
+ See the [wiki](https://github.com/nordstad/zigporter/wiki) for detailed diagrams and architecture docs.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ uv sync --dev
151
+ uv run pytest
152
+ uv run ruff check .
153
+ uv run ruff format .
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,137 @@
1
+ [![CI](https://github.com/nordstad/zigporter/actions/workflows/ci.yml/badge.svg)](https://github.com/nordstad/zigporter/actions/workflows/ci.yml)
2
+ [![codecov](https://codecov.io/gh/nordstad/zigporter/graph/badge.svg)](https://codecov.io/gh/nordstad/zigporter)
3
+ [![Documentation](https://img.shields.io/badge/docs-zensical-blue)](https://nordstad.github.io/zigporter)
4
+ [![PyPI - Version](https://img.shields.io/pypi/v/zigporter)](https://pypi.org/project/zigporter/)
5
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/zigporter)](https://pepy.tech/project/zigporter)
6
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ # zigporter
10
+
11
+ *Because migrating 30 Zigbee devices in Home Assistant by hand is a special kind of misery.*
12
+
13
+ CLI tool that automates the ZHA → Zigbee2MQTT migration in Home Assistant — one device at a
14
+ time, with checkpoints so you can stop and pick up where you left off.
15
+
16
+ > **Early Development Notice**
17
+ > This tool is in early development and has only been tested with one specific setup:
18
+ > - Home Assistant OS 2026.2.3
19
+ > - Supervisor 2026.02.2
20
+ > - Zigbee2MQTT 2.8.0-1
21
+ >
22
+ > I have not had the possibility to test with different HA or Z2M versions and setups.
23
+ > Feedback is very welcome — please open an [issue](https://github.com/nordstad/zigporter/issues) or submit a [PR](https://github.com/nordstad/zigporter/pulls) if you test with a different configuration.
24
+
25
+ > **Early Development Notice**
26
+ > This tool is in early development and has only been tested with one specific setup:
27
+ > - Home Assistant OS 2026.2.3
28
+ > - Supervisor 2026.02.2
29
+ > - Zigbee2MQTT 2.8.0-1
30
+ >
31
+ > I have not had the possibility to test with different HA or Z2M versions and setups.
32
+ > Feedback is very welcome — please open an [issue](https://github.com/nordstad/zigporter/issues) or submit a [PR](https://github.com/nordstad/zigporter/pulls) if you test with a different configuration.
33
+
34
+ ## Requirements
35
+
36
+ - Python 3.12+
37
+ - [uv](https://docs.astral.sh/uv/)
38
+ - Home Assistant with ZHA and Zigbee2MQTT add-on
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ uv tool install zigporter
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ **Option 1 — Setup wizard (recommended)**
49
+
50
+ ```bash
51
+ zigporter setup
52
+ ```
53
+
54
+ Prompts for all values and saves to `~/.config/zigporter/.env`.
55
+
56
+ **Option 2 — Manual config file**
57
+
58
+ Create `~/.config/zigporter/.env` (see `.env.example` for the template):
59
+
60
+ ```bash
61
+ mkdir -p ~/.config/zigporter
62
+ cp .env.example ~/.config/zigporter/.env
63
+ # edit the file with your values
64
+ ```
65
+
66
+ **Option 3 — Environment variables**
67
+
68
+ Export directly in your shell or add to `~/.zshenv` / `~/.bashrc`:
69
+
70
+ ```bash
71
+ export HA_URL=https://your-ha-instance.local
72
+ export HA_TOKEN=your_token
73
+ export Z2M_URL=https://your-ha-instance.local/abc123_zigbee2mqtt
74
+ ```
75
+
76
+ | Variable | Required | Description |
77
+ |---|---|---|
78
+ | `HA_URL` | Yes | Home Assistant URL |
79
+ | `HA_TOKEN` | Yes | [Long-Lived Access Token](https://www.home-assistant.io/docs/authentication/#your-account-profile) |
80
+ | `HA_VERIFY_SSL` | No | `true` / `false` (default: `true`; use `false` for self-signed certs) |
81
+ | `Z2M_URL` | Yes | Zigbee2MQTT ingress URL |
82
+ | `Z2M_MQTT_TOPIC` | No | Z2M base topic (default: `zigbee2mqtt`) |
83
+
84
+ ## Usage
85
+
86
+ ```bash
87
+ # Verify your setup before migrating (recommended first step)
88
+ zigporter check
89
+
90
+ # Run the migration wizard (runs checks automatically on first run)
91
+ zigporter migrate
92
+
93
+ # Check migration progress without entering the wizard
94
+ zigporter migrate --status
95
+
96
+ # (Optional) manually export your ZHA device inventory
97
+ zigporter export
98
+
99
+ # (Optional) inspect what's already in Z2M
100
+ zigporter list-z2m
101
+ ```
102
+
103
+ `zigporter migrate` handles everything automatically on first run:
104
+ 1. Runs pre-flight checks (HA reachable, ZHA active, Z2M running)
105
+ 2. Prompts you to back up Home Assistant and your ZHA network
106
+ 3. Fetches a ZHA export if one is not found, or offers to refresh an existing one
107
+ 4. Opens the interactive migration wizard
108
+
109
+ All files are stored in `~/.config/zigporter/` so the tool works from any directory.
110
+ Use `--skip-checks` on subsequent runs to skip the pre-flight checks.
111
+
112
+ ## How it works
113
+
114
+ The wizard migrates one device at a time through five steps:
115
+
116
+ 1. **Remove from ZHA** — confirms deletion in the HA registry
117
+ 2. **Reset device** — prompts you to factory-reset the physical device
118
+ 3. **Pair with Z2M** — opens a 120 s permit-join window and polls by IEEE address
119
+ 4. **Rename** — applies the original ZHA name and area in Z2M and HA
120
+ 5. **Validate** — polls HA entity states until all are online
121
+
122
+ State is written to `zha-migration-state.json` after every step. `Ctrl-C` marks the device `FAILED` — rerun to retry.
123
+
124
+ See the [wiki](https://github.com/nordstad/zigporter/wiki) for detailed diagrams and architecture docs.
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ uv sync --dev
130
+ uv run pytest
131
+ uv run ruff check .
132
+ uv run ruff format .
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "zigporter"
3
+ version = "0.1.0"
4
+ description = "CLI tool to migrate Zigbee devices from ZHA to Zigbee2MQTT in Home Assistant"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Even Nordstad", email = "even.nordstad@gmail.com" }
8
+ ]
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "typer>=0.12",
13
+ "httpx>=0.27",
14
+ "websockets>=12",
15
+ "pydantic>=2.7",
16
+ "python-dotenv>=1.0",
17
+ "rich>=13",
18
+ "questionary>=2.1.1",
19
+ "platformdirs>=4.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/nordstad/zigporter"
24
+ Repository = "https://github.com/nordstad/zigporter"
25
+ Issues = "https://github.com/nordstad/zigporter/issues"
26
+
27
+ [project.scripts]
28
+ zigporter = "zigporter.main:app"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.9.26,<0.11.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [dependency-groups]
35
+ docs = [
36
+ "zensical",
37
+ ]
38
+ dev = [
39
+ "pytest>=8",
40
+ "pytest-asyncio>=0.23",
41
+ "pytest-cov>=5",
42
+ "respx>=0.21",
43
+ "pytest-mock>=3.14",
44
+ "ruff>=0.4",
45
+ "twine>=5",
46
+ ]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py313"
54
+
File without changes
File without changes
@@ -0,0 +1,230 @@
1
+ import asyncio
2
+
3
+ import httpx
4
+ import questionary
5
+ from rich.console import Console
6
+
7
+ from zigporter.ha_client import HAClient
8
+ from zigporter.models import CheckResult, CheckStatus
9
+
10
+ console = Console()
11
+
12
+ _STYLE = questionary.Style(
13
+ [
14
+ ("qmark", "fg:ansicyan bold"),
15
+ ("question", "bold"),
16
+ ("answer", "fg:ansicyan bold"),
17
+ ("pointer", "fg:ansicyan bold"),
18
+ ("highlighted", "fg:ansicyan bold"),
19
+ ("selected", "fg:ansicyan"),
20
+ ("separator", "fg:ansibrightblack"),
21
+ ("instruction", "fg:ansibrightblack"),
22
+ ("text", ""),
23
+ ("disabled", "fg:ansibrightblack italic"),
24
+ ]
25
+ )
26
+
27
+ _STATUS_ICON = {
28
+ CheckStatus.OK: "[green]✓[/green]",
29
+ CheckStatus.FAILED: "[red]✗[/red]",
30
+ CheckStatus.WARNING: "[yellow]![/yellow]",
31
+ CheckStatus.SKIPPED: "[dim]–[/dim]",
32
+ }
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Individual checks
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ async def _check_config(ha_url: str, token: str, z2m_url: str) -> CheckResult:
41
+ missing = [
42
+ name
43
+ for name, val in [("HA_URL", ha_url), ("HA_TOKEN", token), ("Z2M_URL", z2m_url)]
44
+ if not val
45
+ ]
46
+ if missing:
47
+ return CheckResult(
48
+ name="Configuration",
49
+ status=CheckStatus.FAILED,
50
+ message=f"Missing: {', '.join(missing)} — add to .env or set as environment variables",
51
+ )
52
+ return CheckResult(
53
+ name="Configuration", status=CheckStatus.OK, message="HA_URL, HA_TOKEN, Z2M_URL are set"
54
+ )
55
+
56
+
57
+ async def _check_ha_reachable(ha_url: str, token: str, verify_ssl: bool) -> CheckResult:
58
+ if not ha_url:
59
+ return CheckResult(
60
+ name="HA reachable",
61
+ status=CheckStatus.SKIPPED,
62
+ message="Skipped (no HA_URL configured)",
63
+ )
64
+ try:
65
+ async with httpx.AsyncClient(
66
+ headers={"Authorization": f"Bearer {token}"},
67
+ verify=verify_ssl,
68
+ timeout=10,
69
+ ) as client:
70
+ resp = await client.get(f"{ha_url}/api/")
71
+ resp.raise_for_status()
72
+ return CheckResult(name="HA reachable", status=CheckStatus.OK, message=ha_url)
73
+ except Exception as exc:
74
+ return CheckResult(
75
+ name="HA reachable",
76
+ status=CheckStatus.FAILED,
77
+ message=f"Cannot reach {ha_url} — {exc}",
78
+ )
79
+
80
+
81
+ async def _check_zha_active(ha_url: str, token: str, verify_ssl: bool) -> CheckResult:
82
+ if not ha_url:
83
+ return CheckResult(
84
+ name="ZHA active",
85
+ status=CheckStatus.SKIPPED,
86
+ message="Skipped (no HA_URL configured)",
87
+ )
88
+ try:
89
+ client = HAClient(ha_url, token, verify_ssl)
90
+ devices = await client.get_zha_devices()
91
+ count = len(devices)
92
+ if count == 0:
93
+ return CheckResult(
94
+ name="ZHA active",
95
+ status=CheckStatus.WARNING,
96
+ message="ZHA is reachable but no devices found — is ZHA configured?",
97
+ blocking=False,
98
+ )
99
+ return CheckResult(
100
+ name="ZHA active",
101
+ status=CheckStatus.OK,
102
+ message=f"{count} device(s) found",
103
+ )
104
+ except Exception as exc:
105
+ return CheckResult(
106
+ name="ZHA active",
107
+ status=CheckStatus.FAILED,
108
+ message=f"Could not query ZHA — {exc}",
109
+ )
110
+
111
+
112
+ async def _check_z2m_running(
113
+ ha_url: str, token: str, z2m_url: str, verify_ssl: bool
114
+ ) -> CheckResult:
115
+ if not z2m_url:
116
+ return CheckResult(
117
+ name="Z2M running",
118
+ status=CheckStatus.SKIPPED,
119
+ message="Skipped (no Z2M_URL configured)",
120
+ )
121
+ try:
122
+ async with httpx.AsyncClient(
123
+ headers={"Authorization": f"Bearer {token}"},
124
+ verify=verify_ssl,
125
+ timeout=10,
126
+ ) as client:
127
+ resp = await client.get(f"{z2m_url}/api/devices")
128
+ # Any HTTP response (even 401) means the server is reachable
129
+ if resp.status_code < 500:
130
+ try:
131
+ devices = resp.json()
132
+ count = len(devices) if isinstance(devices, list) else "?"
133
+ return CheckResult(
134
+ name="Z2M running",
135
+ status=CheckStatus.OK,
136
+ message=f"{count} device(s) paired",
137
+ )
138
+ except Exception:
139
+ return CheckResult(
140
+ name="Z2M running",
141
+ status=CheckStatus.OK,
142
+ message="Z2M is responding",
143
+ )
144
+ resp.raise_for_status()
145
+ return CheckResult(
146
+ name="Z2M running", status=CheckStatus.OK, message="Z2M is responding"
147
+ )
148
+ except Exception as exc:
149
+ return CheckResult(
150
+ name="Z2M running",
151
+ status=CheckStatus.FAILED,
152
+ message=f"Cannot reach Zigbee2MQTT at {z2m_url} — {exc}",
153
+ )
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Orchestrator
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ async def _run_checks(
162
+ ha_url: str,
163
+ token: str,
164
+ verify_ssl: bool,
165
+ z2m_url: str,
166
+ ) -> list[CheckResult]:
167
+ results: list[CheckResult] = []
168
+
169
+ config_result = await _check_config(ha_url, token, z2m_url)
170
+ results.append(config_result)
171
+
172
+ # Only run network checks if config is valid
173
+ if config_result.status == CheckStatus.OK:
174
+ ha_result = await _check_ha_reachable(ha_url, token, verify_ssl)
175
+ results.append(ha_result)
176
+
177
+ # ZHA depends on HA being reachable
178
+ if ha_result.status == CheckStatus.OK:
179
+ results.append(await _check_zha_active(ha_url, token, verify_ssl))
180
+ else:
181
+ results.append(
182
+ CheckResult(
183
+ name="ZHA active",
184
+ status=CheckStatus.SKIPPED,
185
+ message="Skipped (HA not reachable)",
186
+ )
187
+ )
188
+
189
+ results.append(await _check_z2m_running(ha_url, token, z2m_url, verify_ssl))
190
+ else:
191
+ for name in ("HA reachable", "ZHA active", "Z2M running"):
192
+ results.append(
193
+ CheckResult(
194
+ name=name, status=CheckStatus.SKIPPED, message="Skipped (invalid config)"
195
+ )
196
+ )
197
+
198
+ return results
199
+
200
+
201
+ def _print_results(results: list[CheckResult]) -> None:
202
+ console.print()
203
+ for r in results:
204
+ icon = _STATUS_ICON[r.status]
205
+ label = f"[bold]{r.name:<20}[/bold]"
206
+ console.print(f" {icon} {label} {r.message}")
207
+ console.print()
208
+
209
+
210
+ def check_command(
211
+ ha_url: str,
212
+ token: str,
213
+ verify_ssl: bool,
214
+ z2m_url: str,
215
+ ) -> bool:
216
+ """Run all preflight checks. Returns True if the user should proceed, False to abort."""
217
+ console.rule("[bold cyan]Pre-flight checks[/bold cyan]")
218
+
219
+ results = asyncio.run(_run_checks(ha_url, token, verify_ssl, z2m_url))
220
+ _print_results(results)
221
+
222
+ blocking_failures = [r for r in results if r.status == CheckStatus.FAILED and r.blocking]
223
+ if blocking_failures:
224
+ console.print("[yellow]One or more checks failed.[/yellow]")
225
+ proceed = questionary.confirm("Proceed anyway?", default=False, style=_STYLE).ask()
226
+ if not proceed:
227
+ return False
228
+
229
+ console.rule()
230
+ return True