Skip to main content
Glama
bennythecoder

lightwave-mcp

README.md
# lightwave-mcp

An [MCP](https://modelcontextprotocol.io) server that lets an AI agent discover and control
LightwaveRF Smart Series ("Link Plus") smart home devices - light switches/dimmers, relays,
and smart power sockets - so a request like "turn off the kitchen light" made to an
MCP-connected agent results in the physical device being controlled.

> **Disclaimer**: this is an independent, community project. It is not affiliated with,
> endorsed by, or supported by LightwaveRF Technology Ltd. You need your own LightwaveRF
> Link Plus account and hardware to use it. LightwaveRF's public API is provided at their
> discretion and may change without notice.

## How it works

LightwaveRF publishes an official, documented REST API at `https://publicapi.lightwaverf.com/`
(separate from the undocumented protocol their own mobile app uses internally). This server
talks to that public API directly over plain HTTPS - there's no persistent connection, no
websocket, and no third-party LightwaveRF client library involved.

```
AI agent (Claude, etc.)
      |  MCP tool calls (stdio)
      v
lightwave-mcp server
      |  HTTPS + bearer token
      v
https://publicapi.lightwaverf.com
      |
      v
Your LightwaveRF Link Plus hub -> physical devices
```

**Authentication.** LightwaveRF uses a Basic token + rotating refresh token pair, generated
once by hand from your account. The server exchanges these for a short-lived access token
via `POST https://auth.lightwaverf.com/token`. Refresh tokens are **single-use**: every
refresh returns a new one that must be used next time. `src/lightwave_mcp/auth.py`
(`TokenManager`) handles this - it caches the live, rotating pair in a local JSON file
(`.lightwave_token_cache.json`, gitignored) so restarts don't reuse an already-consumed
refresh token, and it fails with a clear, actionable error if the pair ever goes dead rather
than retrying silently.

**Discovery.** On startup (and whenever `refresh_devices` is called), the server:
1. `GET /v1/structures` - lists the LightwaveRF "structures" (homes) on the account
2. `GET /v1/structure/{id}` - lists devices and their featureSets/features in each structure
3. `GET /v1/hierarchy/{id}` - maps featureSets to room names, so devices can be reported with
   their room (e.g. "Kitchen")

Each device exposes a set of typed "features" (`switch`, `dimLevel`, `socketSetup`, etc.).
`src/lightwave_mcp/devices.py` classifies each featureSet into `"light"` (has `dimLevel`),
`"outlet"` (has `socketSetup`), or `"switch"` (has `switch` only) - anything else (thermostats,
TRVs, covers, sensors, remotes) is out of scope and filtered out of discovery entirely. A
`DeviceRegistry` builds an id- and name-indexed lookup so tools can resolve a device by either
its LightwaveRF id or the name you gave it in the Lightwave app, and raises a clear error if a
name is ambiguous (two devices sharing a name) or unrecognized.

**Control.** State reads and writes go through `/v1/feature/:featureId` (and the batch
variants `/v1/features/read` / `/v1/features/write`) - there's no push/streaming state, so the
server reads live on every call rather than trusting a cache.

**Errors.** Anything a calling agent needs to react to (device not found, ambiguous name,
wrong device type for an operation, out-of-range brightness, an underlying HTTP failure) is
raised as an MCP `ToolError` with a specific, actionable message, instead of a raw stack trace.

## Setup

### 1. Get a LightwaveRF API token pair

1. Go to [my.lightwaverf.com](https://my.lightwaverf.com) (or the Link Plus app) and sign in.
2. Open **Settings -> API Integration -> Get Token**. You'll be asked for your account
   password again.
3. You'll be given two values: a **Basic token** and a **Refresh token**. Copy both - the
   refresh token is only shown once here (the server keeps it up to date after that; see
   "How it works" above).

Treat both values as secrets - anyone with them can control every device on your account.

### 2. Configure and install

```
cp .env.example .env
```

Edit `.env`:

```
LIGHTWAVE_BASIC_TOKEN=<basic token from step 1>
LIGHTWAVE_REFRESH_TOKEN=<refresh token from step 1>
```

```
python -m venv .venv
.venv/Scripts/activate   # or `source .venv/bin/activate` on macOS/Linux
pip install -e ".[dev]"
```

`.env` and the rotating `.lightwave_token_cache.json` it produces are both gitignored -
never commit either.

### 3. Try it directly

```
python -m lightwave_mcp
```

Starts the stdio MCP server. It fails fast with a clear error if the token pair is missing
or invalid, rather than starting up with zero devices.

### 4. Register with an MCP client (e.g. Claude Code)

```
claude mcp add lightwave -e LIGHTWAVE_BASIC_TOKEN=<basic token> -e LIGHTWAVE_REFRESH_TOKEN=<refresh token> -- python -m lightwave_mcp
```

Then, from a session:

- `list_devices` - see every switch, relay, socket, and light discovered in your home
- `turn_on` / `turn_off` - control one, by name (e.g. "Kitchen Light") or id
- `set_brightness` - dim a light (0-100); turn it on first
- `get_device_state` - check a single device's current state
- `refresh_devices` - re-discover if you've added/removed devices in the Lightwave app

## Tools

| Tool | Description |
| --- | --- |
| `list_devices()` | List all in-scope devices with current state |
| `get_device_state(device)` | Current state of one device |
| `turn_on(device)` | Turn a device on |
| `turn_off(device)` | Turn a device off |
| `set_brightness(device, level)` | Set a light's brightness, 0-100 |
| `refresh_devices()` | Re-run discovery |

`device` accepts either the name shown by `list_devices` (case-insensitive) or its id. If a
name matches more than one device, the error lists the ids to disambiguate with.

## Project layout

```
src/lightwave_mcp/
  auth.py     TokenManager - token exchange/refresh/caching against auth.lightwaverf.com
  client.py   LightwaveClient - thin async HTTP wrapper over publicapi.lightwaverf.com
  devices.py  Device classification, room enrichment, id/name resolution
  server.py   FastMCP tool definitions + startup/shutdown lifecycle
  __main__.py Entry point (stdio transport)
tests/
  test_client.py           TokenManager/LightwaveClient against mocked HTTP (respx)
  test_devices.py          Classification and registry resolution logic
  test_server_tools.py     MCP tool functions against a fake client
  test_integration_live.py Real discovery round trip; skipped unless real tokens are set
```

## Development

```
pip install -e ".[dev]"
pytest
```

`tests/test_integration_live.py` performs a real discovery round trip against your actual
LightwaveRF account and is skipped automatically unless `LIGHTWAVE_BASIC_TOKEN` and
`LIGHTWAVE_REFRESH_TOKEN` are set in the environment - it never runs in CI unless you add
real secrets there deliberately.

## Security notes

- Never commit `.env` or `.lightwave_token_cache.json` - both hold live credentials.
  `.gitignore` already excludes them.
- If you ever suspect a token has leaked, revoke it by generating a fresh pair from
  `my.lightwaverf.com -> Settings -> API Integration -> Get Token` - this invalidates the old
  refresh token.
- This server only exposes switches, relays, sockets, and dimmable lights by design; it never
  reads or writes thermostats, TRVs, or other device types, even though the underlying API
  account may have access to them.

## License

[MIT](LICENSE)