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.
- invisoutlet-1.0.0/LICENSE +21 -0
- invisoutlet-1.0.0/PKG-INFO +125 -0
- invisoutlet-1.0.0/README.md +83 -0
- invisoutlet-1.0.0/pyproject.toml +67 -0
- invisoutlet-1.0.0/setup.cfg +4 -0
- invisoutlet-1.0.0/src/invisoutlet/__init__.py +58 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/__init__.py +8 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/app.py +61 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/__init__.py +34 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/deco.py +77 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/default.py +89 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/device.py +76 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/discover.py +29 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/name.py +51 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/nightlight.py +133 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/ota.py +85 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/outlet.py +54 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/commands/watch.py +56 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/config.py +80 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/formatters.py +37 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/picker.py +87 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/render.py +225 -0
- invisoutlet-1.0.0/src/invisoutlet/cli/state.py +94 -0
- invisoutlet-1.0.0/src/invisoutlet/client.py +1019 -0
- invisoutlet-1.0.0/src/invisoutlet/discovery.py +97 -0
- invisoutlet-1.0.0/src/invisoutlet/exceptions.py +17 -0
- invisoutlet-1.0.0/src/invisoutlet/models.py +536 -0
- invisoutlet-1.0.0/src/invisoutlet/py.typed +0 -0
- invisoutlet-1.0.0/src/invisoutlet.egg-info/PKG-INFO +125 -0
- invisoutlet-1.0.0/src/invisoutlet.egg-info/SOURCES.txt +38 -0
- invisoutlet-1.0.0/src/invisoutlet.egg-info/dependency_links.txt +1 -0
- invisoutlet-1.0.0/src/invisoutlet.egg-info/entry_points.txt +2 -0
- invisoutlet-1.0.0/src/invisoutlet.egg-info/requires.txt +21 -0
- invisoutlet-1.0.0/src/invisoutlet.egg-info/top_level.txt +1 -0
- invisoutlet-1.0.0/tests/test_cli.py +585 -0
- invisoutlet-1.0.0/tests/test_client.py +964 -0
- invisoutlet-1.0.0/tests/test_config.py +74 -0
- invisoutlet-1.0.0/tests/test_discovery.py +87 -0
- invisoutlet-1.0.0/tests/test_models.py +231 -0
- 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
|
+
[](https://github.com/tinkerborg/invisoutlet/actions/workflows/ci.yaml)
|
|
46
|
+
[](https://codecov.io/gh/tinkerborg/invisoutlet)
|
|
47
|
+
[](https://tinkerborg.github.io/invisoutlet/)
|
|
48
|
+
[](https://pypi.org/project/invisoutlet/)
|
|
49
|
+
[](https://pypi.org/project/invisoutlet/)
|
|
50
|
+
[](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
|
+
[](https://github.com/tinkerborg/invisoutlet/actions/workflows/ci.yaml)
|
|
4
|
+
[](https://codecov.io/gh/tinkerborg/invisoutlet)
|
|
5
|
+
[](https://tinkerborg.github.io/invisoutlet/)
|
|
6
|
+
[](https://pypi.org/project/invisoutlet/)
|
|
7
|
+
[](https://pypi.org/project/invisoutlet/)
|
|
8
|
+
[](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,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,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
|
+
)
|