invisoutlet 1.0.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.
Files changed (40) hide show
  1. invisoutlet-1.0.0/LICENSE +21 -0
  2. invisoutlet-1.0.0/PKG-INFO +125 -0
  3. invisoutlet-1.0.0/README.md +83 -0
  4. invisoutlet-1.0.0/pyproject.toml +67 -0
  5. invisoutlet-1.0.0/setup.cfg +4 -0
  6. invisoutlet-1.0.0/src/invisoutlet/__init__.py +58 -0
  7. invisoutlet-1.0.0/src/invisoutlet/cli/__init__.py +8 -0
  8. invisoutlet-1.0.0/src/invisoutlet/cli/app.py +61 -0
  9. invisoutlet-1.0.0/src/invisoutlet/cli/commands/__init__.py +34 -0
  10. invisoutlet-1.0.0/src/invisoutlet/cli/commands/deco.py +77 -0
  11. invisoutlet-1.0.0/src/invisoutlet/cli/commands/default.py +89 -0
  12. invisoutlet-1.0.0/src/invisoutlet/cli/commands/device.py +76 -0
  13. invisoutlet-1.0.0/src/invisoutlet/cli/commands/discover.py +29 -0
  14. invisoutlet-1.0.0/src/invisoutlet/cli/commands/name.py +51 -0
  15. invisoutlet-1.0.0/src/invisoutlet/cli/commands/nightlight.py +133 -0
  16. invisoutlet-1.0.0/src/invisoutlet/cli/commands/ota.py +85 -0
  17. invisoutlet-1.0.0/src/invisoutlet/cli/commands/outlet.py +54 -0
  18. invisoutlet-1.0.0/src/invisoutlet/cli/commands/watch.py +56 -0
  19. invisoutlet-1.0.0/src/invisoutlet/cli/config.py +80 -0
  20. invisoutlet-1.0.0/src/invisoutlet/cli/formatters.py +37 -0
  21. invisoutlet-1.0.0/src/invisoutlet/cli/picker.py +87 -0
  22. invisoutlet-1.0.0/src/invisoutlet/cli/render.py +225 -0
  23. invisoutlet-1.0.0/src/invisoutlet/cli/state.py +94 -0
  24. invisoutlet-1.0.0/src/invisoutlet/client.py +1019 -0
  25. invisoutlet-1.0.0/src/invisoutlet/discovery.py +97 -0
  26. invisoutlet-1.0.0/src/invisoutlet/exceptions.py +17 -0
  27. invisoutlet-1.0.0/src/invisoutlet/models.py +536 -0
  28. invisoutlet-1.0.0/src/invisoutlet/py.typed +0 -0
  29. invisoutlet-1.0.0/src/invisoutlet.egg-info/PKG-INFO +125 -0
  30. invisoutlet-1.0.0/src/invisoutlet.egg-info/SOURCES.txt +38 -0
  31. invisoutlet-1.0.0/src/invisoutlet.egg-info/dependency_links.txt +1 -0
  32. invisoutlet-1.0.0/src/invisoutlet.egg-info/entry_points.txt +2 -0
  33. invisoutlet-1.0.0/src/invisoutlet.egg-info/requires.txt +21 -0
  34. invisoutlet-1.0.0/src/invisoutlet.egg-info/top_level.txt +1 -0
  35. invisoutlet-1.0.0/tests/test_cli.py +585 -0
  36. invisoutlet-1.0.0/tests/test_client.py +964 -0
  37. invisoutlet-1.0.0/tests/test_config.py +74 -0
  38. invisoutlet-1.0.0/tests/test_discovery.py +87 -0
  39. invisoutlet-1.0.0/tests/test_models.py +231 -0
  40. invisoutlet-1.0.0/tests/test_render.py +287 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tinkerborg
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.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: invisoutlet
3
+ Version: 1.0.0
4
+ Summary: Python client library and CLI for InvisOutlet smart outlets
5
+ Author: tinkerborg
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://tinkerborg.github.io/invisoutlet/
8
+ Project-URL: Homepage, https://github.com/tinkerborg/invisoutlet
9
+ Project-URL: Issues, https://github.com/tinkerborg/invisoutlet/issues
10
+ Project-URL: Repository, https://github.com/tinkerborg/invisoutlet
11
+ Keywords: invisoutlet,smart outlet,home automation,iot,websocket
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Home Automation
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.13
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: aiohttp>=3.9
24
+ Requires-Dist: zeroconf>=0.131
25
+ Provides-Extra: cli
26
+ Requires-Dist: typer>=0.12; extra == "cli"
27
+ Requires-Dist: rich>=13; extra == "cli"
28
+ Requires-Dist: questionary>=2.0; extra == "cli"
29
+ Provides-Extra: dev
30
+ Requires-Dist: invisoutlet[cli]; extra == "dev"
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
33
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
34
+ Provides-Extra: docs
35
+ Requires-Dist: invisoutlet[cli]; extra == "docs"
36
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
37
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
38
+ Requires-Dist: mkdocstrings[python]>=0.26; extra == "docs"
39
+ Requires-Dist: mkdocs-click>=0.8; extra == "docs"
40
+ Requires-Dist: mkdocs-gen-files>=0.5; extra == "docs"
41
+ Dynamic: license-file
42
+
43
+ # invisoutlet
44
+
45
+ [![ci](https://github.com/tinkerborg/invisoutlet/actions/workflows/ci.yaml/badge.svg)](https://github.com/tinkerborg/invisoutlet/actions/workflows/ci.yaml)
46
+ [![codecov](https://codecov.io/gh/tinkerborg/invisoutlet/graph/badge.svg)](https://codecov.io/gh/tinkerborg/invisoutlet)
47
+ [![docs](https://github.com/tinkerborg/invisoutlet/actions/workflows/docs.yaml/badge.svg)](https://tinkerborg.github.io/invisoutlet/)
48
+ [![PyPI](https://img.shields.io/pypi/v/invisoutlet)](https://pypi.org/project/invisoutlet/)
49
+ [![Python](https://img.shields.io/pypi/pyversions/invisoutlet)](https://pypi.org/project/invisoutlet/)
50
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/tinkerborg/invisoutlet/blob/HEAD/LICENSE)
51
+
52
+ A Python client library and command-line tool for InvisOutlet smart outlets. It
53
+ talks to the device directly over its local network API, so there's no cloud in
54
+ the loop. This is an independent project, and it isn't affiliated with or
55
+ endorsed by InvisOutlet.
56
+
57
+ ## Features
58
+
59
+ - **Local-only** — speaks the device's on-device WebSocket API directly; no
60
+ cloud account, no internet dependency.
61
+ - **Push-driven** — subscribe to live sensor, outlet, and OTA updates as the
62
+ device emits them; auto-reconnects with backoff.
63
+ - **Full device surface** — outlets, nightlight and Aura color arrays (static
64
+ color/temperature, per-LED palettes, animated effects), sensor readings,
65
+ configuration, accessory names, calibration, and OTA firmware updates.
66
+ - **Typed, async API** — `asyncio` throughout, parsed dataclass models, and
67
+ exceptions on failure (no error codes to check).
68
+ - **`invis` CLI** — discover devices on the network and drive every command
69
+ from the terminal.
70
+ - **Lightweight core** — the library depends only on `aiohttp` and `zeroconf`;
71
+ CLI deps are an optional extra.
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ uv pip install -e ".[dev]" # library + CLI + tests, into the project venv
77
+ uv tool install --editable ".[cli]" # or just the `invis` command, globally
78
+ ```
79
+
80
+ The library itself only needs `aiohttp` and `zeroconf`; the `invis` CLI's
81
+ extra dependencies (`typer`, `rich`, `questionary`) live under the `cli` extra,
82
+ so embedding the library elsewhere stays lightweight.
83
+
84
+ ## Using the library
85
+
86
+ ```python
87
+ import asyncio
88
+ from invisoutlet import InvisOutletClient
89
+
90
+ async def main() -> None:
91
+ async with InvisOutletClient("10.42.44.90") as client:
92
+ print(await client.get_device_info())
93
+ await client.set_outlet(1, True)
94
+
95
+ asyncio.run(main())
96
+ ```
97
+
98
+ ## Using the CLI
99
+
100
+ ```bash
101
+ invis default select # discover, pick, and save a default device
102
+ invis device info
103
+ invis outlet on 1
104
+ invis watch --us
105
+ invis nightlight aura color 200 90
106
+ ```
107
+
108
+ The first time you run a command without a saved device, the CLI scans the
109
+ network and lets you pick one.
110
+
111
+ ## Documentation
112
+
113
+ The full API and CLI reference lives on the
114
+ [documentation site](https://tinkerborg.github.io/invisoutlet/).
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ uv pip install -e ".[dev]"
120
+ uv run pytest -q
121
+ ```
122
+
123
+ ## License
124
+
125
+ [MIT](https://github.com/tinkerborg/invisoutlet/blob/HEAD/LICENSE)
@@ -0,0 +1,83 @@
1
+ # invisoutlet
2
+
3
+ [![ci](https://github.com/tinkerborg/invisoutlet/actions/workflows/ci.yaml/badge.svg)](https://github.com/tinkerborg/invisoutlet/actions/workflows/ci.yaml)
4
+ [![codecov](https://codecov.io/gh/tinkerborg/invisoutlet/graph/badge.svg)](https://codecov.io/gh/tinkerborg/invisoutlet)
5
+ [![docs](https://github.com/tinkerborg/invisoutlet/actions/workflows/docs.yaml/badge.svg)](https://tinkerborg.github.io/invisoutlet/)
6
+ [![PyPI](https://img.shields.io/pypi/v/invisoutlet)](https://pypi.org/project/invisoutlet/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/invisoutlet)](https://pypi.org/project/invisoutlet/)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/tinkerborg/invisoutlet/blob/HEAD/LICENSE)
9
+
10
+ A Python client library and command-line tool for InvisOutlet smart outlets. It
11
+ talks to the device directly over its local network API, so there's no cloud in
12
+ the loop. This is an independent project, and it isn't affiliated with or
13
+ endorsed by InvisOutlet.
14
+
15
+ ## Features
16
+
17
+ - **Local-only** — speaks the device's on-device WebSocket API directly; no
18
+ cloud account, no internet dependency.
19
+ - **Push-driven** — subscribe to live sensor, outlet, and OTA updates as the
20
+ device emits them; auto-reconnects with backoff.
21
+ - **Full device surface** — outlets, nightlight and Aura color arrays (static
22
+ color/temperature, per-LED palettes, animated effects), sensor readings,
23
+ configuration, accessory names, calibration, and OTA firmware updates.
24
+ - **Typed, async API** — `asyncio` throughout, parsed dataclass models, and
25
+ exceptions on failure (no error codes to check).
26
+ - **`invis` CLI** — discover devices on the network and drive every command
27
+ from the terminal.
28
+ - **Lightweight core** — the library depends only on `aiohttp` and `zeroconf`;
29
+ CLI deps are an optional extra.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv pip install -e ".[dev]" # library + CLI + tests, into the project venv
35
+ uv tool install --editable ".[cli]" # or just the `invis` command, globally
36
+ ```
37
+
38
+ The library itself only needs `aiohttp` and `zeroconf`; the `invis` CLI's
39
+ extra dependencies (`typer`, `rich`, `questionary`) live under the `cli` extra,
40
+ so embedding the library elsewhere stays lightweight.
41
+
42
+ ## Using the library
43
+
44
+ ```python
45
+ import asyncio
46
+ from invisoutlet import InvisOutletClient
47
+
48
+ async def main() -> None:
49
+ async with InvisOutletClient("10.42.44.90") as client:
50
+ print(await client.get_device_info())
51
+ await client.set_outlet(1, True)
52
+
53
+ asyncio.run(main())
54
+ ```
55
+
56
+ ## Using the CLI
57
+
58
+ ```bash
59
+ invis default select # discover, pick, and save a default device
60
+ invis device info
61
+ invis outlet on 1
62
+ invis watch --us
63
+ invis nightlight aura color 200 90
64
+ ```
65
+
66
+ The first time you run a command without a saved device, the CLI scans the
67
+ network and lets you pick one.
68
+
69
+ ## Documentation
70
+
71
+ The full API and CLI reference lives on the
72
+ [documentation site](https://tinkerborg.github.io/invisoutlet/).
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ uv pip install -e ".[dev]"
78
+ uv run pytest -q
79
+ ```
80
+
81
+ ## License
82
+
83
+ [MIT](https://github.com/tinkerborg/invisoutlet/blob/HEAD/LICENSE)
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = ["setuptools>=68.0", "wheel"]
4
+
5
+ [project]
6
+ authors = [{name = "tinkerborg"}]
7
+ classifiers = [
8
+ "Development Status :: 5 - Production/Stable",
9
+ "Intended Audience :: Developers",
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ "Programming Language :: Python :: 3.14",
13
+ "Topic :: Home Automation",
14
+ "Operating System :: OS Independent",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = [
18
+ "aiohttp>=3.9",
19
+ "zeroconf>=0.131",
20
+ ]
21
+ description = "Python client library and CLI for InvisOutlet smart outlets"
22
+ keywords = ["invisoutlet", "smart outlet", "home automation", "iot", "websocket"]
23
+ license = "MIT"
24
+ name = "invisoutlet"
25
+ readme = "README.md"
26
+ requires-python = ">=3.13"
27
+ version = "1.0.0"
28
+
29
+ [project.urls]
30
+ Documentation = "https://tinkerborg.github.io/invisoutlet/"
31
+ Homepage = "https://github.com/tinkerborg/invisoutlet"
32
+ Issues = "https://github.com/tinkerborg/invisoutlet/issues"
33
+ Repository = "https://github.com/tinkerborg/invisoutlet"
34
+
35
+ [project.scripts]
36
+ invis = "invisoutlet.cli.app:app"
37
+
38
+ [project.optional-dependencies]
39
+ # The CLI (`invis`) needs these; the library itself does not. Installed via
40
+ # `invisoutlet[cli]`. Kept out of the base deps so embedding the library
41
+ # (e.g. in a Home Assistant integration) doesn't pull in typer/rich/questionary.
42
+ cli = [
43
+ "typer>=0.12",
44
+ "rich>=13",
45
+ "questionary>=2.0",
46
+ ]
47
+ dev = [
48
+ "invisoutlet[cli]",
49
+ "pytest>=8.0",
50
+ "pytest-asyncio>=0.23",
51
+ "pytest-cov>=5.0",
52
+ ]
53
+ docs = [
54
+ "invisoutlet[cli]",
55
+ "mkdocs>=1.6",
56
+ "mkdocs-material>=9.5",
57
+ "mkdocstrings[python]>=0.26",
58
+ "mkdocs-click>=0.8",
59
+ "mkdocs-gen-files>=0.5",
60
+ ]
61
+
62
+ [tool.setuptools.packages.find]
63
+ where = ["src"]
64
+
65
+ [tool.pytest.ini_options]
66
+ asyncio_mode = "auto"
67
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,58 @@
1
+ """Python client library for InvisOutlet smart outlets."""
2
+
3
+ from .client import (
4
+ ColorEffect,
5
+ InvisOutletClient,
6
+ OtaTarget,
7
+ target_for_device_type,
8
+ )
9
+ from .discovery import DiscoveredDevice, discover
10
+ from .exceptions import (
11
+ InvisOutletCommandError,
12
+ InvisOutletConnectionError,
13
+ InvisOutletError,
14
+ InvisOutletTimeoutError,
15
+ )
16
+ from .models import (
17
+ AccessoryName,
18
+ AvailableUpdates,
19
+ ColorLedEntry,
20
+ ColorLightState,
21
+ DeviceConfig,
22
+ DeviceInfo,
23
+ FirmwareRelease,
24
+ FirmwareUpdate,
25
+ NightlightState,
26
+ OtaProgress,
27
+ OtaResult,
28
+ OutletStatus,
29
+ SensorData,
30
+ SubDeviceInfo,
31
+ )
32
+
33
+ __all__ = [
34
+ "AccessoryName",
35
+ "AvailableUpdates",
36
+ "ColorEffect",
37
+ "ColorLedEntry",
38
+ "ColorLightState",
39
+ "DeviceConfig",
40
+ "DeviceInfo",
41
+ "DiscoveredDevice",
42
+ "FirmwareRelease",
43
+ "FirmwareUpdate",
44
+ "InvisOutletClient",
45
+ "InvisOutletCommandError",
46
+ "InvisOutletConnectionError",
47
+ "InvisOutletError",
48
+ "InvisOutletTimeoutError",
49
+ "NightlightState",
50
+ "OtaProgress",
51
+ "OtaResult",
52
+ "OtaTarget",
53
+ "OutletStatus",
54
+ "SensorData",
55
+ "SubDeviceInfo",
56
+ "discover",
57
+ "target_for_device_type",
58
+ ]
@@ -0,0 +1,8 @@
1
+ """Command-line interface for the InvisOutlet client.
2
+
3
+ The console entry point ``invis`` resolves to :data:`app`.
4
+ """
5
+
6
+ from .app import app
7
+
8
+ __all__ = ["app"]
@@ -0,0 +1,61 @@
1
+ """The Typer application and global options.
2
+
3
+ This module defines the ``app`` referenced by the ``invis`` console entry
4
+ point. Commands live in :mod:`invisoutlet.cli.commands` and are attached
5
+ here via each module's ``register`` function.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ import typer
13
+
14
+ from .commands import register_all
15
+ from .state import CLIState
16
+
17
+ app = typer.Typer(
18
+ name="invis",
19
+ help="Command-line tool for InvisOutlet smart outlets.",
20
+ rich_markup_mode="rich",
21
+ no_args_is_help=True,
22
+ add_completion=False,
23
+ )
24
+
25
+
26
+ def _version_callback(value: bool) -> None:
27
+ """Print the package version and exit when ``--version`` is passed."""
28
+ if not value:
29
+ return
30
+ try:
31
+ typer.echo(version("invisoutlet"))
32
+ except PackageNotFoundError:
33
+ typer.echo("unknown")
34
+ raise typer.Exit
35
+
36
+
37
+ @app.callback()
38
+ def main(
39
+ ctx: typer.Context,
40
+ host: str | None = typer.Option(
41
+ None, "--host", help="Device IP address (overrides the saved default)."
42
+ ),
43
+ yes: bool = typer.Option(
44
+ False, "--yes", "-y", help="Skip confirmation for destructive commands."
45
+ ),
46
+ version: bool = typer.Option(
47
+ False,
48
+ "--version",
49
+ callback=_version_callback,
50
+ is_eager=True,
51
+ help="Show the version and exit.",
52
+ ),
53
+ ) -> None:
54
+ """Store global options for the invoked command."""
55
+ ctx.obj = CLIState(host=host, yes=yes)
56
+
57
+
58
+ register_all(app)
59
+
60
+ # Click command object, exposed for docs generation (mkdocs-click).
61
+ cli = typer.main.get_command(app)
@@ -0,0 +1,34 @@
1
+ """Command groups for the InvisOutlet CLI.
2
+
3
+ Each module exposes a ``register(app)`` function that attaches its commands to
4
+ the shared Typer app — most as noun subgroups (`device`, `nightlight`, …), a few
5
+ as flat top-level commands (`watch`, `discover`).
6
+ """
7
+
8
+ from . import (
9
+ deco,
10
+ default,
11
+ device,
12
+ discover,
13
+ name,
14
+ nightlight,
15
+ ota,
16
+ outlet,
17
+ watch,
18
+ )
19
+
20
+
21
+ def register_all(app: object) -> None:
22
+ """Register every command module against the Typer ``app``."""
23
+ default.register(app)
24
+ device.register(app)
25
+ nightlight.register(app)
26
+ outlet.register(app)
27
+ deco.register(app)
28
+ name.register(app)
29
+ discover.register(app)
30
+ ota.register(app)
31
+ watch.register(app)
32
+
33
+
34
+ __all__ = ["register_all"]
@@ -0,0 +1,77 @@
1
+ """The ``deco`` group: control the attached InvisDeco faceplate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from invisoutlet import InvisOutletClient
8
+
9
+ from .. import render
10
+ from ..state import confirm_destructive, run_with_client
11
+
12
+ deco_app = typer.Typer(
13
+ name="deco", help="Control the attached InvisDeco faceplate.", no_args_is_help=True
14
+ )
15
+ calibrate_app = typer.Typer(
16
+ name="calibrate", help="Calibrate the faceplate's sensors.", no_args_is_help=True
17
+ )
18
+ deco_app.add_typer(calibrate_app)
19
+
20
+
21
+ def register(app: typer.Typer) -> None:
22
+ """Attach the ``deco`` group to ``app``."""
23
+ app.add_typer(deco_app)
24
+
25
+
26
+ @deco_app.command()
27
+ def restart(ctx: typer.Context) -> None:
28
+ """Restart the InvisDeco."""
29
+ run_with_client(ctx.obj, _restart)
30
+
31
+
32
+ @deco_app.command()
33
+ def reset(ctx: typer.Context) -> None:
34
+ """Reset the InvisDeco (clears air-quality calibration)."""
35
+ confirm_destructive(ctx.obj, "deco-reset")
36
+ run_with_client(ctx.obj, _reset)
37
+
38
+
39
+ @calibrate_app.command()
40
+ def occupancy(
41
+ ctx: typer.Context,
42
+ seconds: int = typer.Argument(..., help="Calibration duration in seconds."),
43
+ ) -> None:
44
+ """Run occupancy-sensor calibration."""
45
+ run_with_client(ctx.obj, lambda c: _calibrate_occupancy(c, seconds))
46
+
47
+
48
+ @calibrate_app.command()
49
+ def climate(
50
+ ctx: typer.Context,
51
+ temperature: float = typer.Argument(..., help="Reference temperature in °C."),
52
+ humidity: float = typer.Argument(..., help="Reference relative humidity in %."),
53
+ ) -> None:
54
+ """Calibrate temperature and humidity to reference values."""
55
+ run_with_client(ctx.obj, lambda c: _calibrate_climate(c, temperature, humidity))
56
+
57
+
58
+ async def _restart(client: InvisOutletClient) -> None:
59
+ await client.restart_invisdeco()
60
+ render.console.print("[green]✓[/green] InvisDeco restart command sent.")
61
+
62
+
63
+ async def _reset(client: InvisOutletClient) -> None:
64
+ await client.reset_invisdeco()
65
+ render.console.print("[green]✓[/green] InvisDeco reset command sent.")
66
+
67
+
68
+ async def _calibrate_occupancy(client: InvisOutletClient, seconds: int) -> None:
69
+ await client.calibrate_occupancy(seconds)
70
+ render.console.print(f"[green]✓[/green] Occupancy calibration started for {seconds}s.")
71
+
72
+
73
+ async def _calibrate_climate(
74
+ client: InvisOutletClient, temperature: float, humidity: float
75
+ ) -> None:
76
+ await client.calibrate_temp_humidity(temperature, humidity)
77
+ render.console.print(f"[green]✓[/green] Calibrated to {temperature} °C / {humidity}% RH.")
@@ -0,0 +1,89 @@
1
+ """The ``default`` group: manage the persisted default device."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from invisoutlet import InvisOutletClient, InvisOutletError
10
+
11
+ from ..config import (
12
+ DefaultDevice,
13
+ clear_default_device,
14
+ get_default_device,
15
+ set_default_device,
16
+ )
17
+ from ..picker import pick_device
18
+ from ..render import console
19
+
20
+ default_app = typer.Typer(
21
+ name="default",
22
+ help="Manage the default device.",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+
27
+ def register(app: typer.Typer) -> None:
28
+ """Attach the ``default`` group to ``app``."""
29
+ app.add_typer(default_app)
30
+
31
+
32
+ @default_app.command()
33
+ def select(
34
+ host: str | None = typer.Option(
35
+ None, "--host", help="Set this IP directly instead of running the picker."
36
+ ),
37
+ ) -> None:
38
+ """Choose a device (via picker, or --host) and save it as the default."""
39
+ if host is not None:
40
+ device = asyncio.run(_probe(host))
41
+ else:
42
+ chosen = asyncio.run(pick_device())
43
+ if chosen is None:
44
+ raise typer.Exit(code=1)
45
+ device = DefaultDevice(
46
+ host=chosen.host,
47
+ name=chosen.name,
48
+ serial_number=chosen.serial_number,
49
+ )
50
+
51
+ set_default_device(device)
52
+ console.print(
53
+ f"[green]✓[/green] Default device set: {device.name or device.host} "
54
+ f"([cyan]{device.host}[/cyan])."
55
+ )
56
+
57
+
58
+ @default_app.command()
59
+ def show() -> None:
60
+ """Show the saved default device."""
61
+ default = get_default_device()
62
+ if default is None:
63
+ console.print("No default device set.")
64
+ return
65
+ console.print(f"Host: [cyan]{default.host}[/cyan]")
66
+ console.print(f"Name: {default.name or '—'}")
67
+ console.print(f"Serial: {default.serial_number or '—'}")
68
+
69
+
70
+ @default_app.command()
71
+ def forget() -> None:
72
+ """Forget the saved default device."""
73
+ clear_default_device()
74
+ console.print("[green]✓[/green] Default device cleared.")
75
+
76
+
77
+ async def _probe(host: str) -> DefaultDevice:
78
+ """Best-effort fetch of a device's name/serial for the saved entry.
79
+
80
+ Falls back to just the host if the device cannot be reached.
81
+ """
82
+ try:
83
+ async with InvisOutletClient(host) as client:
84
+ info = await client.get_device_info()
85
+ except InvisOutletError:
86
+ return DefaultDevice(host=host)
87
+ return DefaultDevice(
88
+ host=host, name=info.device or None, serial_number=info.serial_number or None
89
+ )