Metadata-Version: 2.4
Name: fritzctl-py
Version: 0.2.0
Summary: Controls your Fritz Home thermostates from the command line or via the MCP protocol.
Project-URL: Homepage, https://github.com/jenreh/python-kit
Project-URL: Repository, https://github.com/jenreh/python-kit
Author: Jens Rehpöhler
License: MIT
License-File: LICENSE.md
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Home Automation
Requires-Python: >=3.14
Requires-Dist: aiosqlite==0.22.1
Requires-Dist: defusedxml>=0.7.1
Requires-Dist: fastmcp==3.3.1
Requires-Dist: httpx>=0.28.1
Requires-Dist: keyring>=25.7.0
Requires-Dist: pydantic-settings==2.14.1
Requires-Dist: pydantic==2.13.4
Requires-Dist: rich==15.0.0
Requires-Dist: typer==0.25.1
Description-Content-Type: text/markdown

# fritzctl — FRITZ!Home CLI & MCP Server

![Version](https://img.shields.io/badge/version-0.2.0-blue)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
[![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)

**fritzctl** controls FRITZ!DECT radiator thermostats directly via your FRITZ!Box — no cloud, no account, no internet required after first setup.

---

## Features

- **Pure local control** — authenticates against your FRITZ!Box via challenge-response; all traffic stays on your LAN
- **Dual API support** — Smart Home REST API (FRITZ!OS 8.20+) with automatic fallback to the legacy AHA XML API
- **Rich CLI** — human-readable tables or `--json` for scripts and pipes
- **MCP server** — expose your thermostats as tools to Claude or any MCP client
- **Safety layer** — enforces temperature bounds (8–28 °C), max delta (5 °C/operation), cooldown windows, and device lock/battery checks before every write
- **Audit log** — every write operation is recorded to `~/.config/fritz-local/audit.log`
- **Hub discovery** — SSDP/UPnP M-SEARCH scan; no IP guessing needed
- **Keyring integration** — credentials stored securely in the OS keyring, never in plain text

---

## Installation

```bash
pip install fritzctl
```

Requires Python 3.14+.

---

## Quick start

### 1. Discover your FRITZ!Box

```bash
fritzctl discover
```

```text
         FRITZ!Box Devices Found
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓
┃ IP            ┃ URL                   ┃ Model           ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩
│ 192.168.178.1 │ http://192.168.178.1  │ FRITZ!Box 7590  │
└───────────────┴───────────────────────┴─────────────────┘
```

### 2. Authenticate and save credentials

```bash
fritzctl setup
```

Credentials are saved to the OS keyring and `~/.fritzhome/config.json`. You only need to do this once.

### 3. Use it

```bash
fritzctl list
fritzctl status "Wohnzimmer"
fritzctl set-temp "Wohnzimmer" 21.5
fritzctl enable-boost "Kinderzimmer" --duration-seconds 600
```

---

## CLI reference

```text
fritzctl [--json] <command> [args]
```

| Command | Description |
| --- | --- |
| `fritzctl discover` | Scan the LAN for FRITZ!Box devices via SSDP/UPnP |
| `fritzctl setup` | Authenticate and persist credentials to keyring |
| `fritzctl health` | Check FRITZ!Box connectivity and API type |
| `fritzctl list` | List all thermostats and groups |
| `fritzctl status [DEVICE]` | Show temperature, battery, and lock state |
| `fritzctl set-temp DEVICE TEMP` | Set target temperature (°C) with safety check |
| `fritzctl enable-boost DEVICE` | Activate boost mode for a duration |
| `fritzctl disable-window-open DEVICE` | Cancel window-open mode early |

All write commands support `--dry-run` (plan only, no execution) and `--no-confirm` (skip interactive prompt).

### Exit codes

| Code | Meaning |
| --- | --- |
| `0` | success |
| `1` | error (authentication, network, safety violation) |

### Safety guardrails

Every write command is checked by the `SafetyPolicyEngine` before execution:

- Temperature must be between **8 °C** and **28 °C**
- Change cannot exceed **±5 °C** in a single operation
- Device must be **online**, **unlocked**, and have **battery > 10 %**
- A **cooldown window** prevents repeat writes in quick succession

> [!NOTE]
> Device names or AIns are both accepted as identifiers. If a device is temporarily offline, the local cache (`~/.fritzhome/config.json`) is used to resolve names to AIns.

---

## MCP server

**fritzctl** ships with an MCP server that exposes your thermostats as tools for Claude or any MCP-compatible client.

> [!WARNING]
> Write tools are disabled by default. Set `FRITZ_MCP_ALLOW_WRITES=true` to enable `set_heater_temperature`, `enable_boost`, and `disable_window_open`.

### Claude Desktop

Add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "fritz": {
      "command": "fritzctl-mcp",
      "args": [],
      "env": {
        "FRITZ_BOX_URL": "http://192.168.178.1",
        "FRITZ_MCP_ALLOW_WRITES": "true"
      }
    }
  }
}
```

### VS Code (agent mode)

```json
{
  "mcp": {
    "servers": {
      "fritz": {
        "command": "fritzctl-mcp",
        "type": "stdio",
        "env": {
          "FRITZ_BOX_URL": "http://192.168.178.1",
          "FRITZ_MCP_ALLOW_WRITES": "true"
        }
      }
    }
  }
}
```

### Available MCP tools

`list_heaters` · `get_heater_status` · `get_group_status` · `set_heater_temperature` · `enable_boost` · `disable_window_open`

---

## Configuration

Credentials and URL are stored in `~/.fritzhome/config.json` (non-sensitive) and the OS keyring (password). You can also use environment variables:

| Variable | Description |
| --- | --- |
| `FRITZ_BOX_URL` | FRITZ!Box base URL (default: `http://192.168.178.1`) |
| `FRITZ_BOX_USERNAME` | Username (leave unset for password-only boxes) |
| `FRITZ_BOX_PASSWORD` | Password fallback when keyring is unavailable |
| `FRITZ_MCP_ALLOW_WRITES` | Set to `true` to enable write tools in the MCP server |

Precedence: keyring / config file > env var > default.

---

## Python library

```python
import asyncio
from fritzctl.avm.clients import fritz_client_context


async def main() -> None:
    async with fritz_client_context() as client:
        devices = await client.list_devices()
        for d in devices:
            print(f"{d.name}: {d.current_temp:.1f}°C → {d.target_temp:.1f}°C")

        await client.set_temperature("12345 67890", 21.0)


asyncio.run(main())
```

---

## Development

```bash
git clone https://github.com/jenreh/fritzhome-py
cd fritzhome-py
uv sync
task test     # pytest with coverage
task lint     # ruff + mypy
task format   # ruff format
```

> [!NOTE]
> Tests use `pytest-httpserver` to mock the FRITZ!Box HTTP API — no real hardware required.
