diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..641eda9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install -e ".[dev]" + - run: test -x script/install && sh -n script/install + - run: ruff format --check poolctl tests + - run: ruff check poolctl tests + - run: detect-secrets scan --baseline .secrets.baseline + - run: pytest -q diff --git a/.gitignore b/.gitignore index e691fb0..9c550ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ .venv/ __pycache__/ .pytest_cache/ -*.pyc +*.py[cod] *.egg-info/ .env .vscode/ .idea/ +.ruff_cache/ +config/adapter.json +build/ +dist/ diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..186f6c2 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,139 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [ + "^\\.git/", + "^docs/assets/", + "^\\.secrets\\.baseline$" + ] + } + ], + "results": {}, + "generated_at": "2026-08-16T20:10:38Z" +} diff --git a/AGENTS.md b/AGENTS.md index dbdafce..e4b8fa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,72 +1,41 @@ -# AGENTS.md - -## What this repo is - -`poolctl` is a terminal-first Pentair ScreenLogic exploration and control project. - -The goal is not to build a bloated smart-home platform on day one. The goal is to build: -- a clean library layer -- a sharp CLI -- small, understandable commands -- enough protocol understanding to control real hardware safely - -Think: UNIX tool, not enterprise sludge. - -## Project principles - -- **Library first, CLI immediately useful.** - The protocol/control logic should be reusable, while the CLI stays pleasant for direct human use. - -- **Read before write.** - Prefer adding discovery, inventory, and state inspection before mutating commands. - -- **Small commands, low surprise.** - Commands should do one thing well and print useful output. - -- **Test the rendering and logic layers.** - Unit tests should cover summary/formatting/helpers even when live hardware tests are limited. - -- **Treat live pool hardware with respect.** - Avoid risky or surprising writes. Add guardrails around mutating commands. - -- **Hide vendor friction only when explicitly desired.** - Prefer small sharp commands over over-smart orchestration. Keep cleaner control and delay cancellation as separate explicit commands unless the user wants otherwise. - -- **Update docs when the shape settles.** - When a change feels right, update `README.md` and `AGENTS.md` in the same stretch of work. Clean code and current docs go together. - -- **Prefer pipx for installed CLI usage.** - For daily use, these tools should behave like normal commands on the user path. Reserve local venv activation for development and testing. - -## Current shape - -- `poolctl/gateway.py` — adapter discovery, cached adapter resolution, and live status fetch -- `poolctl/config.py` — tiny config/cache for adapter connection details +# Repository guidance + +## Purpose + +`poolctl` is a terminal-first Pentair ScreenLogic inspection and control tool. +Keep the reusable protocol/control layer separate from presentation, and keep +the installed CLI useful without requiring repository knowledge. + +## Engineering principles + +- Read state before adding or performing a write. +- Keep commands small, explicit, and scriptable. +- Treat live pool hardware as safety-sensitive. +- Require a deliberate guard for every mutating command. +- Report post-write state instead of equating request acceptance with success. +- Keep real adapter names, IPs, and site topology outside the public repository. +- Preserve compact human output and stable JSON output. +- Test pure selection, rendering, configuration, and protocol logic without + requiring live hardware. +- Update `README.md`, `SKILL.md`, and relevant files under `docs/` when command + behavior changes. +- Maintain `script/install` as the language-neutral deployment contract. A + future Rust migration changes that script, not private bootstrap callers. + +## Layout + +- `poolctl/gateway.py` — discovery, cached adapter resolution, and status fetch +- `poolctl/config.py` — private local adapter configuration - `poolctl/render.py` — summary shaping and human-readable output -- `poolctl/control.py` — small explicit control helpers for cleaner and delay actions -- `poolctl/protocol.py` — protocol gaps not covered by screenlogicpy -- `poolctl/cli.py` — command-line entrypoint -- `tests/` — unit tests for pure logic/rendering/config helpers - -`SKILL.md` lives at the repo root so Botty can use this CLI directly when Chris asks for pool actions in chat. - -## Near-term roadmap - -1. Keep the cleaner/delay interface sharp and boring -2. Robust circuit lookup by name/id -3. Better structured JSON output -4. Add explicit config commands if useful (`config show`, maybe `config set-host`) -5. Optional Rust port later if the Python shape proves right - -## Style - -- Keep code boring and readable. -- Avoid needless framework energy. -- Prefer explicit names over magic. -- Don’t let the repo turn into app-store cosplay. - -## Vibe - -This project is a small terminal trident for poking a cursed pool box over the LAN. - -🌊🤖🔱 +- `poolctl/control.py` — guarded cleaner and delay helpers +- `poolctl/protocol.py` — protocol behavior missing from `screenlogicpy` +- `poolctl/cli.py` — command-line parser and dispatch +- `script/install` — stable installer entry point for deployment automation +- `tests/` — hardware-free unit tests +- `docs/` — operations, protocol, troubleshooting, and roadmap notes + +## Development + +Prefer `pipx` for daily installed use and `.venv` for development. Run the full +format, lint, secret-scan, and test sequence documented in `README.md` before +publishing. Never make a live hardware write as part of an automated test. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..770738a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chris Berry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4b4bbd7..569b306 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,137 @@ -# poolctl 🌊🤖🔱 +

+ Illustration of a terminal monitoring pool equipment and a cleaner +

-A terminal-first Pentair ScreenLogic control tool for interrogating and nudging a mildly cursed pool box over the LAN. +# poolctl -`poolctl` is part of the same small-tool family as `lightctl` and `hottubctl`: sharp commands, readable output, no app-shaped nonsense. +`poolctl` is a small Python CLI for inspecting and operating a Pentair +ScreenLogic pool system over the local network. It discovers and remembers the +adapter, renders compact equipment state, exposes structured JSON, and provides +guarded cleaner and delay controls. + +> [!WARNING] +> `poolctl` controls pumps and pool equipment through a local, unofficial +> ScreenLogic integration. Keep people clear of equipment, preserve working +> physical controls, and do not use this project for unattended safety-critical +> automation. ## What it does -- discovers ScreenLogic adapters on the local network -- shows current pool/spa status in a compact CLI format -- inspects circuits, bodies, and pumps -- checks and controls the cleaner -- checks and cancels system delay state +- discovers a ScreenLogic adapter on the local subnet; +- reads controller, body, circuit, pump, and sensor state; +- reports cleaner and system-delay state; +- turns the cleaner on or off after an explicit `--yes` guard; +- cancels active delays after an explicit `--yes` guard; +- cancels a cleaner delay before enabling the cleaner, then reports final state. + +Python 3.11 or newer is required. ## Install ```bash -git clone git@github.com:your-user/poolctl.git +git clone https://github.com/cnberry/poolctl.git cd poolctl -just install +./script/install ``` -That installs `poolctl` with `pipx` so it behaves like a normal command, not a repo you have to babysit. +`script/install` is the stable repository contract used by private deployment +automation. Today it installs the Python package with `pipx`; it can be replaced +by a Rust or binary installer later without changing callers. `just install` +uses the same contract. + +## Configure private adapter data -Useful `just` targets: +Run discovery once on the same LAN as the ScreenLogic adapter: ```bash -just setup -just test -just test-integration -just test-all -just status -just circuits -just bodies -just pumps -just raw +poolctl discover ``` -## Local config - -`poolctl` stores discovered adapter config here: -- `~/.config/poolctl/config.json` - -That file is local machine state, not repo content. +The selected adapter is saved to `~/.config/poolctl/config.json` with mode +`0600`. Set `POOLCTL_CONFIG=/path/to/config.json` to use another private file, +or pass `--host 192.0.2.10` before a command for a one-off host override. -## Commands +Adapter IPs, names, and site topology are private deployment data. Keep real +values in a private configuration repository, not in a public fork. A sanitized +shape is provided in [`config/adapter.example.json`](config/adapter.example.json). -Core inspection: -- `poolctl discover` -- `poolctl status` -- `poolctl circuits` -- `poolctl bodies` -- `poolctl pumps` +## Inspect state -Cleaner control: -- `poolctl cleaner status` -- `poolctl cleaner on --yes` -- `poolctl cleaner off --yes` - -Delay control: -- `poolctl delay status` -- `poolctl delay cancel --yes` - -Debugging / direct host override: -- `poolctl --host 192.168.1.50 status` +```bash +poolctl status +poolctl circuits +poolctl bodies +poolctl pumps +poolctl cleaner status +poolctl delay status +``` -Default output is compact and human-readable. Use `--json` for structured output. -Use `poolctl status --raw` when you want the raw payload. +Add `--json` after a command for structured output. `poolctl status --raw` +prints the adapter payload for protocol diagnosis and may include private local +topology, so review it before sharing. -Example status output: +## Control the cleaner and delays -```text -Model: EasyTouch2 4P -Air: 64°F -Salt: 2750 ppm -Bodies: -- Pool: 66°F, heat_mode=Solar, setpoint=85°F, heat_state=Off -- Spa: 65°F, heat_mode=Off, setpoint=100°F, heat_state=Off +```bash +poolctl cleaner on --yes +poolctl cleaner off --yes +poolctl delay cancel --yes ``` -## Development +Write commands refuse to run without `--yes`. Cleaner enable checks delay state, +cancels an active cleaner delay, performs the circuit write, and reads status +again before reporting success. See [operations](docs/operations.md) for the +full behavior and safety boundary. -For early development or protocol poking: +## Runtime data -```bash -python3 -m venv .venv -. .venv/bin/activate -pip install -r requirements.txt -pip install pytest -PYTHONPATH=. pytest -q -python poolctl.py status -``` +| Data | Default path | Git policy | +| --- | --- | --- | +| Adapter cache | `~/.config/poolctl/config.json` | Private config repo only | +| Raw status output | Standard output only | Review before sharing | -Once the CLI is useful, prefer the installed command shape via `just install` / `just reinstall`. +`poolctl` does not require a cloud username, password, or token. -## Why this repo exists +## Reliability and scope -The goal is not to build a giant pool platform. The goal is to make the useful 90% easy: -- inspect the system quickly -- script common actions -- avoid phone-app friction -- keep the interface boring enough to trust +ScreenLogic discovery is LAN-bound, and equipment names and supported sensors +vary by controller configuration. `poolctl` deliberately exposes only the +cleaner circuit and delay cancellation as writes; it is not a general arbitrary +circuit-toggle interface. -## Extra docs +See [protocol notes](docs/protocol.md), [troubleshooting](docs/troubleshooting.md), +and the [roadmap](docs/roadmap.md) for more detail. -- `AGENTS.md` — project principles and engineering intent -- `SKILL.md` — lets an agent/chat workflow drive the CLI directly +## Control-tool family -## Built with +- [`gatectl`](https://github.com/cnberry/gatectl) — MyQ gate and garage-door + status with guarded open/close. +- [`poolctl`](https://github.com/cnberry/poolctl) — Pentair ScreenLogic status, + cleaner, and delay control. +- [`hottubctl`](https://github.com/cnberry/hottubctl) — Sundance SmartTub + temperature and freshness inspection. +- [`switchctl`](https://github.com/cnberry/switchctl) — named local switch + status and guarded power control. -This repo was created with: -- OpenClaw 2026.3.28 (`f9b1079`) -- OpenAI GPT 5.4 +Current and future `*ctl` tools favor small commands, private configuration, +readable output, safe JSON, guarded writes, post-write readback, a repo-owned +`script/install`, and explicit uncertainty. -## Current shape +## Development + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install -e ".[dev]" +.venv/bin/ruff format --check poolctl tests +.venv/bin/ruff check poolctl tests +.venv/bin/detect-secrets scan --baseline .secrets.baseline +.venv/bin/pytest -q +``` -This repo is the most mature of the three `*ctl` siblings. -It already covers the everyday useful stuff and serves as the pattern for the others: -- installable CLI -- compact human-readable output -- local config outside the repo -- explicit commands instead of magical orchestration +Unit tests cover configuration, selection logic, rendering, and protocol +constants without contacting live pool equipment. `just test-integration` only +checks the installed command surface; live hardware validation remains manual. -## Agent-first repo notes +## License -This repo is intended to be agent-friendly as well as human-friendly. -The standard agent-first files live at the repo root: -- `README.md` — human-facing overview -- `AGENTS.md` — project principles and working conventions -- `SKILL.md` — direct agent usage guidance +`poolctl` is released under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ece527c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +`poolctl` can operate physical pool equipment. Report vulnerabilities privately +through GitHub's security-advisory feature instead of opening a public issue +with site topology, adapter addresses, raw payloads, or exploit details. + +Never include real adapter names, local IPs, network captures, credentials from +adjacent systems, or private automation configuration in a report. + +This project uses an unofficial local integration and cannot provide safety, +availability, or security guarantees for ScreenLogic or connected equipment. diff --git a/SKILL.md b/SKILL.md index 4b4e291..561c50c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,89 +1,40 @@ --- name: poolctl -description: Control a local Pentair ScreenLogic pool setup from the `poolctl` CLI. Use when asked to turn the pool cleaner on or off, check cleaner or delay status, cancel pool delays, inspect pool status/circuits/bodies/pumps, or otherwise operate the local pool system from this repo. +description: Inspect and control a local Pentair ScreenLogic pool system with the poolctl CLI. Use for pool status, circuits, bodies, pumps, cleaner state, cleaner control, and delay inspection or cancellation. --- # poolctl -Use the local `poolctl` CLI from this repository. +Use the installed `poolctl` CLI instead of ad-hoc protocol calls when a command +already exists. -## Rules +## Safety rules -- Prefer the `poolctl` CLI over ad-hoc Python or direct protocol poking when the command already exists. -- Use the configured adapter by default. -- Keep responses short and action-oriented. -- For mutating commands, report the final compact CLI output, not internal debug details. -- If a requested behavior is not implemented in `poolctl`, say so plainly and then improve the CLI if appropriate. +- Read state before a write when the request or current state is ambiguous. +- Treat adapter IPs, names, and raw payloads as private deployment data. +- Use `--yes` only after the requested equipment and action are clear. +- Report final state from the command, not merely that a write was submitted. +- Never invent support for an arbitrary circuit; the public write surface is + intentionally limited to cleaner and delay commands. -## Run from repo root - -Preferred daily-use flow: +## Commands ```bash -cd REPO_ROOT/poolctl +poolctl discover poolctl status -``` - -Cached adapter config lives at: -- `~/.config/poolctl/config.json` - -## Command map - -### Cleaner - -```bash +poolctl circuits +poolctl bodies +poolctl pumps poolctl cleaner status poolctl cleaner on --yes poolctl cleaner off --yes -``` - -Use for requests like: -- "turn on my pool cleaner" -- "turn off the cleaner" -- "is the pool cleaner on?" - -### Delays - -```bash poolctl delay status poolctl delay cancel --yes ``` -Use for requests like: -- "cancel pool delays" -- "clear system delay" -- "is there a cleaner delay?" - -### General inspection - -```bash -poolctl status -poolctl circuits -poolctl bodies -poolctl pumps -poolctl discover -``` - -### Direct host override - -Only use when debugging or explicitly requested: - -```bash -poolctl --host 192.168.1.50 status -``` - -## Chat-level workflow - -When Chris says "turn on my pool cleaner", prefer this sequence: -1. `poolctl cleaner on --yes` -2. trust the command's final reported status, because it should now auto-cancel cleaner delay when needed and report post-action cleaner/delay state -3. if something still looks wrong, run `poolctl cleaner status` explicitly and report the final concise result - -## Response style - -Examples: -- "Done. Cleaner: on" -- "Done. Delays: cleaner=0 pool=0 spa=0" -- "Cleaner: off" +Use `--json` for structured results. Put a one-off direct host before the +subcommand, for example `poolctl --host 192.0.2.10 status`. -If a command fails, quote the relevant error briefly and say what you’ll do next. +Cleaner enable already checks and cancels cleaner delay when necessary, then +reports the post-action cleaner and delay state. If a command fails, quote the +short error and do not claim the hardware reached the requested state. diff --git a/config/adapter.example.json b/config/adapter.example.json new file mode 100644 index 0000000..1acbad1 --- /dev/null +++ b/config/adapter.example.json @@ -0,0 +1,9 @@ +{ + "adapter": { + "gsubtype": 0, + "gtype": 0, + "ip": "192.0.2.10", + "name": "Pentair: EXAMPLE", + "port": 80 + } +} diff --git a/discover.py b/discover.py deleted file mode 100644 index 8fb7fb1..0000000 --- a/discover.py +++ /dev/null @@ -1,13 +0,0 @@ -import asyncio -import json - -from screenlogicpy import discovery - - -async def main() -> None: - hosts = await discovery.async_discover() - print(json.dumps(hosts, indent=2, sort_keys=True, default=str)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/docs/assets/poolctl-hero.jpg b/docs/assets/poolctl-hero.jpg new file mode 100644 index 0000000..04a6266 Binary files /dev/null and b/docs/assets/poolctl-hero.jpg differ diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..2e4b09c --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,35 @@ +# Operations and safety + +## Read before write + +Use `poolctl cleaner status` or `poolctl delay status` before a write when the +requested action or current state is unclear. All writes require `--yes`; this +flag is a deliberate automation guard, not evidence that the physical area is +safe. + +## Cleaner enable sequence + +`poolctl cleaner on --yes`: + +1. reads the current delay state; +2. cancels an active cleaner delay; +3. resolves the single circuit named `Cleaner`; +4. writes the enabled state; +5. refreshes controller data; and +6. reports post-action cleaner and delay state. + +Circuit selection refuses missing or ambiguous matches. The CLI does not expose +a general arbitrary-circuit write command. + +## Delay cancellation + +`poolctl delay cancel --yes` sends the pinned ScreenLogic cancel-delay request, +refreshes controller state, and reports cleaner, pool, and spa delay values. +Canceling a delay can cause scheduled equipment to resume; inspect the system +and physical area first. + +## Live validation + +Automated tests never contact pool hardware. Before a release that changes write +behavior, validate status, cleaner off/on/off, and delay reporting on supervised +equipment, recording only sanitized results. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..c594140 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,15 @@ +# Protocol notes + +`poolctl` uses `screenlogicpy` for adapter discovery, connection, state refresh, +and cleaner-circuit writes. The adapter is normally discovered by LAN broadcast +and cached outside the repository. + +Delay cancellation is implemented in `poolctl/protocol.py` because the pinned +dependency does not expose that request as a public helper. The request opcode +is locked by a unit test. The implementation currently needs two private +`screenlogicpy` gateway attributes; dependency upgrades therefore require a +focused compatibility review and supervised hardware validation. + +Equipment names, sensors, pumps, and body fields vary by controller and site +configuration. Human output is a curated summary; `status --raw` exists for +diagnosis and should not be treated as a stable public schema. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..4b9180d --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,29 @@ +# Roadmap + +## Proven surface + +- LAN adapter discovery and private caching +- compact controller, body, circuit, pump, and sensor status +- JSON and raw diagnostic output +- guarded cleaner on/off with delay handling and post-write readback +- guarded delay cancellation + +## Next + +- remove reliance on private `screenlogicpy` attributes for delay cancellation; +- add mocked CLI dispatch tests and stable JSON-schema notes; +- improve errors for discovery, timeout, and protocol-version failures; +- document supervised validation across more ScreenLogic controller families. + +## Implementation direction + +A future Rust port should preserve the CLI, private config paths, redaction, +JSON contract, and `script/install` entry point. Keep the Python implementation +until the replacement reaches behavioral and safety parity. + +## Out of scope by default + +- arbitrary circuit writes; +- unattended safety-critical automation; +- storing public site topology or credentials; +- claiming broad hardware compatibility without validation. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..47ac8ee --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,25 @@ +# Troubleshooting + +## No adapter is discovered + +- Run from the same local network as the ScreenLogic adapter. +- Check client isolation, VLAN, VPN, and broadcast-filtering settings. +- Try a one-off direct host: `poolctl --host 192.0.2.10 status`. +- Delete or replace the private adapter cache if its address changed. + +## A cached adapter no longer responds + +Without `--host`, `poolctl` falls back to discovery after a failed cached +connection and saves the newly discovered adapter. A direct host failure is +returned without silently choosing a different controller. + +## Cleaner lookup fails + +The guarded command requires one unambiguous circuit matching `Cleaner`. Inspect +`poolctl circuits` and verify the controller's circuit name. The CLI intentionally +does not guess among several partial matches. + +## A write is refused + +Mutating commands require `--yes`. Re-run only after verifying the intended +equipment and that the physical area is clear. diff --git a/justfile b/justfile index 2f643a5..f7c5625 100644 --- a/justfile +++ b/justfile @@ -9,18 +9,19 @@ default: just --list install: - pipx install --editable . + ./script/install reinstall: - -pipx uninstall poolctl - pipx install --editable . + ./script/install setup: python3 -m venv {{venv}} - {{pip}} install -e . - {{pip}} install pytest + {{pip}} install -e ".[dev]" test: + {{venv}}/bin/ruff format --check poolctl tests + {{venv}}/bin/ruff check poolctl tests + {{venv}}/bin/detect-secrets scan --baseline .secrets.baseline PYTHONPATH=. {{pytest}} -q test-integration: diff --git a/poolctl.py b/poolctl.py deleted file mode 100644 index 913b8e1..0000000 --- a/poolctl.py +++ /dev/null @@ -1,5 +0,0 @@ -from poolctl.cli import main - - -if __name__ == "__main__": - main() diff --git a/poolctl/cli.py b/poolctl/cli.py index 45ce427..f89b553 100644 --- a/poolctl/cli.py +++ b/poolctl/cli.py @@ -37,7 +37,9 @@ async def async_main() -> None: delay_status_parser = delay_sub.add_parser("status") delay_status_parser.add_argument("--json", action="store_true") delay_cancel_parser = delay_sub.add_parser("cancel") - delay_cancel_parser.add_argument("--yes", action="store_true", help="actually perform the hardware write") + delay_cancel_parser.add_argument( + "--yes", action="store_true", help="actually perform the hardware write" + ) delay_cancel_parser.add_argument("--json", action="store_true") args = parser.parse_args() @@ -65,7 +67,9 @@ async def async_main() -> None: enabled = args.cleaner_command == "on" if not args.yes: action = "on" if enabled else "off" - raise SystemExit(f"Refusing to turn cleaner {action} without --yes. Run: poolctl cleaner {action} --yes") + raise SystemExit( + f"Refusing to turn cleaner {action} without --yes. Run: poolctl cleaner {action} --yes" + ) delay_before = await delay_status(args.host) cancelled_delay = None @@ -98,11 +102,15 @@ async def async_main() -> None: if args.json: print(json.dumps(status, indent=2, sort_keys=True, default=str)) else: - print(f"Delays: cleaner={status['cleaner']} pool={status['pool']} spa={status['spa']}") + print( + f"Delays: cleaner={status['cleaner']} pool={status['pool']} spa={status['spa']}" + ) return if not args.yes: - raise SystemExit("Refusing to cancel delays without --yes. Run: poolctl delay cancel --yes") + raise SystemExit( + "Refusing to cancel delays without --yes. Run: poolctl delay cancel --yes" + ) result = await cancel_delay(args.host) if args.json: diff --git a/poolctl/config.py b/poolctl/config.py index 4beba4b..1495c65 100644 --- a/poolctl/config.py +++ b/poolctl/config.py @@ -1,22 +1,37 @@ from __future__ import annotations import json +import os from pathlib import Path from typing import Any +ENV_CONFIG_PATH = "POOLCTL_CONFIG" CONFIG_DIR = Path.home() / ".config" / "poolctl" CONFIG_PATH = CONFIG_DIR / "config.json" +def config_path() -> Path: + override = os.environ.get(ENV_CONFIG_PATH) + return Path(override).expanduser() if override else CONFIG_PATH + + def load_config() -> dict[str, Any]: - if not CONFIG_PATH.exists(): + path = config_path() + if not path.exists(): return {} - return json.loads(CONFIG_PATH.read_text()) + return json.loads(path.read_text()) def save_config(config: dict[str, Any]) -> None: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - CONFIG_PATH.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n") + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + path.parent.chmod(0o700) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w") as config_file: + json.dump(config, config_file, indent=2, sort_keys=True) + config_file.write("\n") + path.chmod(0o600) def get_adapter_config() -> dict[str, Any] | None: diff --git a/poolctl/control.py b/poolctl/control.py index 2bb6b13..1f51d82 100644 --- a/poolctl/control.py +++ b/poolctl/control.py @@ -4,7 +4,7 @@ from screenlogicpy import ScreenLogicGateway -from poolctl.gateway import discover_adapter, fetch_status, resolve_adapter +from poolctl.gateway import fetch_status, resolve_adapter from poolctl.protocol import async_request_cancel_delay from poolctl.render import summarize @@ -51,7 +51,9 @@ async def delay_status(host: str | None = None) -> dict[str, int | None]: return extract_delay(payload["data"]) -async def set_circuit_state(circuit_name: str, enabled: bool, host: str | None = None) -> dict[str, Any]: +async def set_circuit_state( + circuit_name: str, enabled: bool, host: str | None = None +) -> dict[str, Any]: payload = await fetch_status(host) summary = summarize(payload) circuit = find_circuit(summary, circuit_name) diff --git a/poolctl/protocol.py b/poolctl/protocol.py index 4772e22..d7c9d8a 100644 --- a/poolctl/protocol.py +++ b/poolctl/protocol.py @@ -4,7 +4,6 @@ from screenlogicpy.requests.request import async_make_request - CANCEL_DELAY_QUERY = 12580 diff --git a/pyproject.toml b/pyproject.toml index 4635d4a..0d6804c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,44 @@ build-backend = "setuptools.build_meta" [project] name = "poolctl" -version = "0.1.0" -description = "Terminal-first Pentair ScreenLogic exploration and control" +version = "0.2.0" +description = "Terminal-first Pentair ScreenLogic inspection and guarded control" readme = "README.md" requires-python = ">=3.11" +license = "MIT" +authors = [{name = "Chris Berry"}] +keywords = ["pentair", "screenlogic", "pool", "home-automation", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Home Automation", +] dependencies = [ "screenlogicpy==0.10.2", ] +[project.optional-dependencies] +dev = ["detect-secrets>=1.5,<2", "pytest>=8,<9", "ruff>=0.9,<1"] + +[project.urls] +Homepage = "https://github.com/cnberry/poolctl" +Issues = "https://github.com/cnberry/poolctl/issues" + [project.scripts] poolctl = "poolctl.cli:main" +[tool.setuptools] +packages = ["poolctl"] + [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ec700c5..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -screenlogicpy==0.10.2 diff --git a/script/install b/script/install new file mode 100755 index 0000000..9b4253a --- /dev/null +++ b/script/install @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +if ! command -v pipx >/dev/null 2>&1; then + echo "poolctl: the current Python implementation requires pipx" >&2 + echo "poolctl: install pipx, or replace script/install when a binary implementation ships" >&2 + exit 1 +fi + +exec pipx install --force "$repo_root" diff --git a/status.py b/status.py deleted file mode 100644 index 0e97028..0000000 --- a/status.py +++ /dev/null @@ -1,33 +0,0 @@ -import asyncio -import json -from typing import Any - -from screenlogicpy import ScreenLogicGateway, discovery - - -async def fetch() -> dict[str, Any]: - hosts = await discovery.async_discover() - if not hosts: - raise RuntimeError("No ScreenLogic adapters discovered on local subnet") - - host = hosts[0] - gateway = ScreenLogicGateway() - await gateway.async_connect(**host) - try: - await gateway.async_update() - data = gateway.get_data() - return { - "adapter": host, - "data": data, - } - finally: - await gateway.async_disconnect() - - -async def main() -> None: - payload = await fetch() - print(json.dumps(payload, indent=2, sort_keys=True, default=str)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/test_config.py b/tests/test_config.py index b1da03f..c62796f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,18 +1,42 @@ +import stat + from poolctl.config import get_adapter_config, load_config, set_adapter_config def test_load_config_missing(monkeypatch, tmp_path): monkeypatch.setattr("poolctl.config.CONFIG_DIR", tmp_path / ".config" / "poolctl") - monkeypatch.setattr("poolctl.config.CONFIG_PATH", tmp_path / ".config" / "poolctl" / "config.json") + monkeypatch.setattr( + "poolctl.config.CONFIG_PATH", tmp_path / ".config" / "poolctl" / "config.json" + ) assert load_config() == {} assert get_adapter_config() is None def test_set_and_get_adapter_config(monkeypatch, tmp_path): monkeypatch.setattr("poolctl.config.CONFIG_DIR", tmp_path / ".config" / "poolctl") - monkeypatch.setattr("poolctl.config.CONFIG_PATH", tmp_path / ".config" / "poolctl" / "config.json") - set_adapter_config({"ip": "192.168.1.50", "port": 80, "name": "Pentair: EXAMPLE", "gtype": 2, "gsubtype": 12}) + monkeypatch.setattr( + "poolctl.config.CONFIG_PATH", tmp_path / ".config" / "poolctl" / "config.json" + ) + set_adapter_config( + { + "ip": "192.0.2.10", + "port": 80, + "name": "Pentair: EXAMPLE", + "gtype": 2, + "gsubtype": 12, + } + ) adapter = get_adapter_config() - assert adapter["ip"] == "192.168.1.50" + assert adapter["ip"] == "192.0.2.10" assert adapter["port"] == 80 assert adapter["name"] == "Pentair: EXAMPLE" + mode = stat.S_IMODE((tmp_path / ".config" / "poolctl" / "config.json").stat().st_mode) + assert mode == 0o600 + + +def test_config_path_can_be_overridden(monkeypatch, tmp_path): + override = tmp_path / "private" / "pool.json" + monkeypatch.setenv("POOLCTL_CONFIG", str(override)) + set_adapter_config({"ip": "192.0.2.10", "port": 80}) + assert override.exists() + assert get_adapter_config()["ip"] == "192.0.2.10" diff --git a/tests/test_render.py b/tests/test_render.py index 9a624b9..17ae67b 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,9 +1,17 @@ -from poolctl.render import enum_value, onoff, render_bodies, render_circuits, render_pumps, render_status, summarize +from poolctl.render import ( + enum_value, + onoff, + render_bodies, + render_circuits, + render_pumps, + render_status, + summarize, +) def sample_payload(): return { - "adapter": {"name": "Pentair: EXAMPLE", "ip": "192.168.1.50", "port": 80}, + "adapter": {"name": "Pentair: EXAMPLE", "ip": "192.0.2.10", "port": 80}, "data": { "controller": { "model": {"value": "EasyTouch2 4P"}, @@ -29,8 +37,20 @@ def sample_payload(): }, }, "circuit": { - "500": {"circuit_id": 500, "name": "Spa", "value": 0, "function": 1, "interface": 1}, - "505": {"circuit_id": 505, "name": "Pool", "value": 1, "function": 2, "interface": 0}, + "500": { + "circuit_id": 500, + "name": "Spa", + "value": 0, + "function": 1, + "interface": 1, + }, + "505": { + "circuit_id": 505, + "name": "Pool", + "value": 1, + "function": 2, + "interface": 0, + }, }, "pump": { "0": { @@ -73,7 +93,7 @@ def test_renderers(): bodies = render_bodies(summary) pumps = render_pumps(summary) - assert "Adapter: Pentair: EXAMPLE @ 192.168.1.50:80" in status + assert "Adapter: Pentair: EXAMPLE @ 192.0.2.10:80" in status assert "500 off Spa" in circuits assert "Pool: 66°F, heat_mode=Solar" in bodies assert "pump 0: on rpm=2750 watts=1036 gpm=255" in pumps