# cnMaestro Cleanup + Bulk Firmware Upgrade Tool

**Date:** 2026-05-09
**Status:** Approved, implementation starting

## Goal

Build a Python CLI for cnMaestro Cloud (cloud.cambiumnetworks.com) that:
1. Removes devices offline for > 24 hours (configurable threshold).
2. Submits bulk firmware upgrade jobs to bring online devices to the latest
   firmware available for their model.

## Non-Goals

- NetBox sync (separate concern).
- Daemon/scheduling (use cron + `--yes`).
- Firmware rollback (cnMaestro handles).
- Custom firmware uploads (use cnMaestro UI).

## Architecture

```
cnmaestro/
├── README.md                  # Setup, API client creation, usage
├── .env.example               # CNMAESTRO_CLIENT_ID / _SECRET / _BASE_URL
├── pyproject.toml             # uv project
├── cnmaestro/
│   ├── __init__.py
│   ├── client.py              # OAuth2 + paginated httpx wrapper
│   ├── devices.py             # list, partition, delete
│   ├── firmware.py            # latest-per-model, job submission
│   └── cli.py                 # click CLI
└── tests/
    ├── test_client.py
    ├── test_devices.py
    └── test_firmware.py
```

## API Reference (cnMaestro Cloud v2)

- **Auth:** `POST /api/v2/access/token` with `client_credentials` grant →
  bearer token. Cache in-memory, re-fetch on 401.
- **List devices:** `GET /api/v2/devices?limit=100&offset=N`. Key fields:
  `mac`, `name`, `network`, `tower`, `status` ("online"/"offline"),
  `last_sync` (epoch ms or ISO 8601), `product`, `software_version`.
- **Delete device:** `DELETE /api/v2/devices/{mac}`.
- **Firmware images:** `GET /api/v2/firmware`. Returns image metadata
  with `product` and `version` fields per image.
- **Submit job:** `POST /api/v2/devices/jobs/firmware` with
  `{ "devices": ["MAC", ...], "package": "<image-name-or-version>" }`.
  Limit 100 MACs per job.

## Behavior

### `cnmaestro cleanup [--dry-run] [--yes] [--threshold-hours 24] [--force]`

1. Fetch all devices (paginated).
2. Partition: online / offline-recent / offline >24h / never-synced.
3. Removal candidates = offline >24h ∪ never-synced.
4. Render `rich` table: name, MAC, product, last_sync, tower.
5. Show counts. If candidates > 50% of fleet, refuse unless `--force`.
6. Prompt; on confirm, DELETE each with progress bar; collect failures.

### `cnmaestro upgrade [--dry-run] [--yes] [--product P1,P2,...]`

1. Fetch all devices, keep `status=online`.
2. Fetch firmware images list.
3. For each product, pick highest semver as target.
4. Build groups `(product, target_version) → [mac, ...]` for devices
   where `software_version != target`.
5. Render summary table.
6. Prompt; submit one POST per group, chunked to 100 MACs. Print job IDs.

## Safety Rails

- Default to dry-run summary + interactive confirm.
- 50%-of-fleet sanity check on cleanup.
- Token never persisted.
- All destructive ops appended to `./cnmaestro.log` (timestamped).
- Tests use `respx` to mock httpx; CI never hits live API.

## Testing Strategy

- Unit tests for partition logic (no HTTP).
- Unit tests for "latest version per model" with fixture firmware list.
- HTTP-mocked tests for client pagination, 401 retry, delete.
- No live API tests in CI.

## Setup (User)

1. Log into cloud.cambiumnetworks.com.
2. Manage Services → API Clients → Add Client.
3. Copy client ID + secret into `.env`.
4. `uv sync && uv run cnmaestro cleanup --dry-run` to validate.
