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 🌊🤖🔱
+
+
+
-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