routers update

This commit is contained in:
Graham McIntire 2026-05-10 14:48:52 -05:00
parent 39c6843a5c
commit ab6ac75391
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
11 changed files with 102 additions and 210 deletions

196
CLAUDE.md
View file

@ -4,163 +4,75 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a network documentation and discovery project that integrates with NetBox.
A small fleet of CLIs for documenting, auditing, and operating the vntx WISP — a multi-tower MikroTik backbone with cnMaestro and UISP managing the radios/CPE behind it. Source of truth for network documentation is NetBox at `https://netbox.vntx.net/` (API token in env var `NETBOX_KEY`); source of truth for the routers is whatever the live `.rsc` exports say.
## NetBox Integration
## Repo Layout
- **NetBox URL**: https://netbox.vntx.net/
- **API Key**: e50298f7fd20f7fd6f1931f635511b34f6e8cfde
- **Purpose**: Network documentation and discovery
The repo is a collection of independent CLIs that each operate on the same physical fleet but talk to different control planes. Each subproject has its own README/CLAUDE.md with details; only repo-wide context lives here.
## Development Guidelines
| Subdir | Lang | Talks to | Purpose |
|--|--|--|--|
| `mikrotik-tool/` | Go | MikroTik API-SSL + SSH | Fan-out to every router in `routers.yaml`: `list`, `export` (saves `<name>.rsc` per router), `inventory` (rewrites `inventory.yaml` from live data + SNMP), `api`/`schema` (ad-hoc API introspection on one router), `script` (push a `.rsc` file via SSH and stream output). Authoritative `inventory.yaml`, `subnets.yaml`, `radios.yaml`, `ipv6.md`, and `mpls.md` live here. |
| `mikrotik-tool-rs/` | Rust | MikroTik API-SSL + system `ssh` | Newer rewrite of a subset of `mikrotik-tool` (`list`, `export`, `run <cmd…>`) with `-r name1,name2` filter to scope a fan-out command. Has its own `routers.yaml`. See `mikrotik-tool-rs/CLAUDE.md`. |
| `uisp/` | Go (`main.go`) + Python (`uisp.py`) | UISP API at `uisp.vntx.net` | List/approve/upgrade UISP-managed devices. Auth via `UISP_KEY` env var. UNKNOWN-model devices are filtered by default (use `--include-unknown`). |
| `cnmaestro/` | Python (uv) | cnMaestro Cloud OAuth2 | Cleanup offline devices and bulk-upgrade firmware. `uv sync` + `.env` with client id/secret. See `cnmaestro/README.md`. |
### NetBox API Usage
- Use the provided API key for authentication with NetBox
- The NetBox instance is located at https://netbox.vntx.net/
- Follow NetBox API documentation for proper endpoint usage
- The netbox api token is in env var NETBOX_KEY
`routers.yaml` at the repo root is the config consumed by `mikrotik-tool/` (Go); `mikrotik-tool-rs/` has its own copy. Keep them aligned when adding/removing routers.
### Security Notes
- Never commit API keys directly in code files
- Use environment variables or configuration files for sensitive data
- The API key provided should be stored securely
`poll_snmp_site.sh` is a one-off helper for SNMP-polling a list of IPs at a site (`./poll_snmp_site.sh <site> <ip>...`). The `*.rsc` files at the repo root are stale exports — fresh ones come out of `mikrotik-tool` or `mikrotik-tool-rs`.
## Common Tasks
## Common Commands
### NetBox API Connection
When connecting to NetBox, use:
- Base URL: `https://netbox.vntx.net/api/`
- Authentication header: `Authorization: Token e50298f7fd20f7fd6f1931f635511b34f6e8cfde`
### MikroTik Router Connection
Connect to MikroTik routers using API-SSL:
- **Protocol**: API-SSL (port 8729)
- **Username**: `grahamro` (read-only)
- **Password**: `cFKhz8q5gPLoucMbcT1Iy58r3IXgc3`
- **Example - Verona Router**: `10.254.254.101`
Use the `mikrotik_connect.py` script to connect and retrieve router information:
```bash
python3 mikrotik_connect.py
# mikrotik-tool (Go)
cd mikrotik-tool && go build .
./mikrotik-tool list
./mikrotik-tool export # writes <name>.rsc per router into cwd
./mikrotik-tool api verona /ip/route/print
./mikrotik-tool schema verona /routing/bgp/connection
./mikrotik-tool script 494 494-v6-apply.rsc
./mikrotik-tool inventory # re-renders inventory.yaml in place
go test ./... # inventory_test.go
# mikrotik-tool-rs (Rust)
cd mikrotik-tool-rs && cargo build --release
cargo run -- list
cargo run -- -r verona,982 export # filter to a subset before the verb
cargo run -- run /system identity print
cargo test # full suite
cargo test encode_length # single test by name substring
cargo clippy --all-targets && cargo fmt
# uisp (Go binary, also a Python script)
cd uisp && go build .
./uisp list
./uisp approve <device>
./uisp upgrade
# python equivalent:
python3 uisp.py list
# cnmaestro (Python, uv)
cd cnmaestro && uv sync
uv run cnmaestro cleanup --dry-run
uv run cnmaestro upgrade --product ePMP,PMP --dry-run
uv run pytest
uv run pytest tests/test_devices.py::test_specific_thing # single test
```
The script handles SSL connection, authentication, and can retrieve:
- IP addresses and subnets
- Interface configurations
- Routing tables
- PPPoE connections
`UISP_KEY` and `NETBOX_KEY` need to be exported in the shell. `cnmaestro` reads `cnmaestro/.env`.
## Router Access Credentials
## NetBox
### MikroTik Routers
- Read-only access via API-SSL: username `grahamro`, password `cFKhz8q5gPLoucMbcT1Iy58r3IXgc3`
- URL: `https://netbox.vntx.net/`, API base `https://netbox.vntx.net/api/`
- Auth: `Authorization: Token $NETBOX_KEY`
- Convention when creating sites/devices: site slugs are lowercase-hyphenated, status `active`; routers are Manufacturer=MikroTik / Role=Router / primary IP = loopback `/32` (`10.254.254.x/32`).
- Prefix roles in use: Infrastructure, Customer, Management, Loopback.
### Verona Routers
- Verona router is 10.254.254.101
## MikroTik Router Access
### Additional Router IP Addresses
- Climax router: 10.254.254.102
- Culleoka router: 10.254.254.104
Read-only fan-out (used by all the above tools): API-SSL on port 8729, user `grahamro` / password in `CLAUDE.md` history. The committed `routers.yaml` actually uses `graham` (not `grahamro`) because `/export` requires the `ftp` policy in addition to `read,api`, which the read-only group lacks. New routers: ensure the user's group has `read,api,ftp` (or extend the read group: `/user group set read add-policy=ftp`).
## NetBox Site and Device Creation Process
### Creating a new site and router in NetBox:
1. **Create Site**: Use `create_verona_site_and_router.py` as template
- Site name and slug (lowercase, hyphenated)
- Status: 'active'
- Comments describing the site
2. **Create Device**:
- Manufacturer: MikroTik
- Device Type: RouterBOARD
- Device Role: Router
- Primary IP: Router's loopback IP (e.g., 10.254.254.101/32)
3. **Add Network Data**: Use `update_netbox_verona.py` as template
- Creates prefixes with proper roles (Infrastructure, Customer, Management, Loopback)
- Creates interfaces on the device
- Associates IP addresses with interfaces
### API Authentication
- Always use environment variable `NETBOX_KEY` for API token
- Fallback to hardcoded token only if env var not set
## Generic Scripts for Network Management
### 1. Create Site Only in NetBox
```bash
# Basic usage
python3 create_site_only.py <site_name>
# With options
python3 create_site_only.py 380 --comments "Central site with multiple routers" --address "380 Main St"
```
### 2. Create Site and Router in NetBox
```bash
# Basic usage
python3 create_site_and_router.py <site_name> <router_ip>
# With options
python3 create_site_and_router.py Climax 10.254.254.102 --router-name climax-core --physical-address "123 Tower Rd"
# For sites with multiple routers, create site first, then add each router
python3 create_site_only.py 380 --comments "Multi-router site"
python3 create_site_and_router.py 380 10.254.254.105 --router-name 380-core-router
python3 create_site_and_router.py 380 10.254.254.106 --router-name 380-edge-router
```
### 3. Get MikroTik Router Configuration
```bash
# Basic usage (uses default read-only credentials)
python3 get_mikrotik_router_data.py <router_ip>
# Save to specific file
python3 get_mikrotik_router_data.py 10.254.254.102 -o climax_config.json
# Output JSON to stdout
python3 get_mikrotik_router_data.py 10.254.254.102 --json
# Custom credentials
python3 get_mikrotik_router_data.py 10.254.254.102 -u admin -p secretpass
```
The script retrieves:
- Router identity
- IP addresses and subnets
- Active interfaces
- VLANs
- PPPoE servers
- Static routes
### 4. Get MikroTik Router Data (Basic/Older RouterOS)
For older RouterOS versions or routers with many IPs (like CGNAT):
```bash
python3 get_mikrotik_basic_data.py <router_ip> -o router_data.json
```
This simplified script:
- Groups CGNAT addresses into a single subnet entry
- Focuses on key interfaces only
- Works better with older RouterOS versions
- Handles large configurations without timing out
### 5. Get All Network Devices
```bash
# Get all devices categorized by type
python3 get_all_network_devices.py <router_ip>
# Show all devices including "Other" category
python3 get_all_network_devices.py <router_ip> --show-all
# Save to JSON file
python3 get_all_network_devices.py <router_ip> -o devices.json
```
This script retrieves DHCP leases and ARP table to identify:
- Ubiquiti access points and devices
- MikroTik devices
- Other network devices
Every router's loopback is `10.254.254.x/32`; see the **Fleet Topology** table below for the full mapping.
## Network Topology Patterns

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.21.4
# 2026-05-09 21:18:17 by RouterOS 7.21.4
# software id = K4QG-8NQV
#
# model = RB5009UG+S+
@ -378,19 +378,15 @@ add bridge=management interface=vlan10_ether7 internal-path-cost=10 \
add bridge=management interface=vlan10_ether8 internal-path-cost=10 \
path-cost=10
/ip firewall connection tracking
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \
tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \
tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \
tcp-time-wait-timeout=2m udp-stream-timeout=2m
/ip neighbor discovery-settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set discover-interface-list=!dynamic
/ip settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set tcp-syncookies=yes
/ipv6 settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set accept-router-advertisements=no
/interface pppoe-server server
add default-profile=494 disabled=no interface=494 max-mru=1492 max-mtu=1492 \
@ -543,8 +539,6 @@ add address=10.250.1.70%ether2-climax area=ospf-area-1 disabled=no \
poll-interval=10s
/snmp
set contact=graham@vntx.net enabled=yes location="33.208204, -96.462530"
/system clock
set time-zone-name=America/Chicago
/system identity
set name=494
/system logging
@ -560,7 +554,6 @@ add address=0.us.pool.ntp.org
/system package update
set channel=long-term
/system routerboard settings
# Firmware upgraded successfully, please reboot for changes to take effect!
set auto-upgrade=yes
/system scheduler
add name=reboot on-event="/system reboot" policy=\

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.21.4
# 2026-05-10 14:48:48 by RouterOS 7.21.4
# software id = FUVS-HCM5
#
# model = CCR1009-7G-1C-1S+

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:04 by RouterOS 7.21.4
# 2026-05-09 16:18:38 by RouterOS 7.21.4
# software id = UETF-WF31
#
# model = CCR2004-16G-2S+
@ -299,19 +299,15 @@ add bridge=climax-bridge interface=sfp-sfpplus1
add bridge=mgmt interface=vlan10_ether7
add bridge=mgmt interface=vlan10_sfp-sfpplus1
/ip firewall connection tracking
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set enabled=yes icmp-timeout=30s tcp-close-wait-timeout=1m \
tcp-established-timeout=4h tcp-fin-wait-timeout=2m tcp-last-ack-timeout=\
30s tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \
tcp-time-wait-timeout=2m udp-stream-timeout=2m
/ip neighbor discovery-settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set discover-interface-list=!dynamic
/ip settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set max-neighbor-entries=8192 tcp-syncookies=yes
/ipv6 settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set accept-router-advertisements=no
/interface pppoe-server server
add authentication=chap,mschap2 default-profile=pppoe disabled=no interface=\
@ -528,5 +524,4 @@ add address=0.us.pool.ntp.org
/system package update
set channel=long-term
/system routerboard settings
# Firmware upgraded successfully, please reboot for changes to take effect!
set auto-upgrade=yes enter-setup-on=delete-key

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.21.4
# 2026-05-09 16:20:09 by RouterOS 7.21.4
# software id = XS5B-41QR
#
# model = CCR1009-7G-1C-1S+
@ -372,17 +372,13 @@ add name=logs remote=204.110.191.229 src-address=10.254.254.253 target=remote
/ip smb
set enabled=no
/ip firewall connection tracking
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set tcp-established-timeout=4h tcp-fin-wait-timeout=2m tcp-time-wait-timeout=\
2m
/ip neighbor discovery-settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set discover-interface-list=!dynamic
/ip settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set max-neighbor-entries=8192 tcp-syncookies=yes
/ipv6 settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set accept-router-advertisements=no max-neighbor-entries=8192 \
soft-max-neighbor-entries=8191
/interface pppoe-server server

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.21.4
# 2026-05-09 16:21:31 by RouterOS 7.21.4
# software id = HVP9-3G0K
#
# model = CCR1009-7G-1C-1S+
@ -375,16 +375,13 @@ add bridge=culleoka disabled=yes ingress-filtering=no interface=*10 \
add bridge=mgmt ingress-filtering=no interface=vlan10_ether2 \
internal-path-cost=10 path-cost=10
/ip firewall connection tracking
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \
tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \
tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \
tcp-time-wait-timeout=2m udp-stream-timeout=2m
/ip settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set tcp-syncookies=yes
/ipv6 settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set accept-router-advertisements=no
/interface pppoe-server server
add accept-empty-service=no authentication=chap,mschap2 default-profile=pppoe \
@ -725,7 +722,6 @@ add address=0.us.pool.ntp.org
/system package update
set channel=long-term
/system routerboard settings
# Firmware upgraded successfully, please reboot for changes to take effect!
set auto-upgrade=yes
/system scheduler
add interval=5m name=remove_hotspot on-event=\

View file

@ -1,4 +1,4 @@
# may/09/2026 15:08:04 by RouterOS 6.49.18
# may/10/2026 05:53:37 by RouterOS 6.49.18
# software id = 8XZE-R7EJ
#
# model = CCR2004-1G-12S+2XS

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.22.3
# 2026-05-10 14:48:48 by RouterOS 7.22.3
# software id = ZGNY-ZJW7
#
# model = RB5009UG+S+
@ -100,7 +100,6 @@ add bridge=bridge comment=defconf ingress-filtering=no interface=sfp-sfpplus1 \
internal-path-cost=10 path-cost=10
add bridge=dockers interface=veth1
/ipv6 settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set accept-router-advertisements=yes
/interface detect-internet
set detect-interface-list=all internet-interface-list=all lan-interface-list=\
@ -430,7 +429,7 @@ add comment=wigle.net disabled=no distance=1 dst-address=54.70.85.50/32 \
add dst-address=100.64.0.0/10 gateway=172.17.0.2
add comment="Default via TMO (primary)" disabled=no distance=1 dst-address=\
0.0.0.0/0 gateway=192.168.12.1 target-scope=11
add comment="Default via VNTX (secondary)" disabled=no distance=2 \
add comment="Default via VNTX (secondary)" disabled=yes distance=2 \
dst-address=0.0.0.0/0 gateway=204.110.191.30 target-scope=11
add check-gateway=ping comment="Default via Starlink (last\
\n resort)" distance=3 dst-address=0.0.0.0/0 gateway=4.2.2.3 \
@ -512,7 +511,6 @@ set enabled=yes
/system ntp client servers
add address=ntp.vntx.net
/system routerboard settings
# Firmware upgraded successfully, please reboot for changes to take effect!
set auto-upgrade=yes
/tool e-mail
set certificate-verification=no from=mikrotik@vntx.net port=2525 server=\

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.21.4
# 2026-05-10 14:48:49 by RouterOS 7.21.4
# software id = 5HTF-YFWV
#
# model = CCR1009-7G-1C-1S+

View file

@ -1,4 +1,4 @@
# 2026-05-09 15:08:05 by RouterOS 7.21.4
# 2026-05-09 16:19:30 by RouterOS 7.21.4
# software id = Y1CT-1WB1
#
# model = CCR2004-16G-2S+
@ -456,19 +456,15 @@ add bridge=*1F interface=*23 internal-path-cost=10 path-cost=10
add bridge=cpe_vlan_10 interface=vlan10_ether15
add bridge=cpe_vlan_10 interface=ether10-powerswitch
/ip firewall connection tracking
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \
tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \
tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \
tcp-time-wait-timeout=2m udp-stream-timeout=2m
/ip neighbor discovery-settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set discover-interface-list=!dynamic
/ip settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set tcp-syncookies=yes
/ipv6 settings
# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings
set accept-router-advertisements=no
/interface pppoe-server server
add default-profile=pppoe-verona disabled=no interface=verona max-mru=1500 \
@ -1025,7 +1021,6 @@ add address=0.us.pool.ntp.org
/system package update
set channel=long-term
/system routerboard settings
# Firmware upgraded successfully, please reboot for changes to take effect!
set auto-upgrade=yes enter-setup-on=delete-key
/system scheduler
add name=reboot on-event="/system reboot" policy=\

View file

@ -134,6 +134,21 @@ def latest_firmware_for(fws: list[dict], d: dict) -> str:
return best
def upgrade_in_flight(d: dict, target_v: str = "") -> bool:
"""True if UISP already has a pending/running upgrade for this device.
Re-POSTing /tasks for a device with one queued returns a misleading
422 "Firmware not found" error; skip these instead.
"""
up = d.get("upgrade") or {}
status = (up.get("status") or "").lower()
if status not in {"queued", "running", "in-progress", "started", "preparing", "downloading", "installing"}:
return False
if not target_v:
return True
# Only skip if the queued upgrade is for the same target we'd request.
return up.get("firmwareVersion") == target_v
def needs_upgrade(d: dict, fws: list[dict]) -> bool:
ident = d.get("identification") or {}
if ident.get("type") == "blackBox":
@ -147,7 +162,11 @@ def needs_upgrade(d: dict, fws: list[dict]) -> bool:
latest = latest_firmware_for(fws, d)
if not latest:
return False
return version_lt(cur, latest)
if not version_lt(cur, latest):
return False
if upgrade_in_flight(d, latest):
return False
return True
def is_unauthorized(d: dict) -> bool:
@ -595,7 +614,7 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
print(f"\ndry-run: pass --yes to actually trigger upgrades.")
return
batch_size = max(1, args.batch_size)
batch_size = args.batch_size if args.batch_size > 0 else len(targets)
n_batches = (len(targets) + batch_size - 1) // batch_size
failed_total = 0
@ -603,45 +622,35 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
batch = targets[batch_idx * batch_size:(batch_idx + 1) * batch_size]
print(f"\n=== batch {batch_idx + 1}/{n_batches}: {len(batch)} device(s) ===")
# Build the upgrades payload and POST one task per device. UISP's
# actual upgrade endpoint is POST /tasks with
# {upgrades: [{deviceId, firmwareVersion}], upgradeInMaintenanceWindow}.
# /devices/{id}/update only refreshes device data — it does NOT
# trigger a firmware upgrade.
# POST /tasks one device at a time. Bulk submission (all device
# upgrades in a single /tasks payload) is atomic: any one device
# whose target firmwareVersion doesn't exactly match a catalog
# entry causes the whole call to 422 with "Firmware not found".
# Per-device /tasks isolates failures.
triggered: list[tuple[str, str, str]] = [] # (id, name, target_version)
upgrades = []
for d in batch:
did = d["identification"]["id"]
target_v = latest_firmware_for(fws, d)
if not target_v:
print(f" skip {did} {disp_name(d)}: no catalog firmware match", file=sys.stderr)
print(f" skip {did} {disp_name(d):<30}: no catalog firmware match", file=sys.stderr)
failed_total += 1
continue
upgrades.append({
"deviceId": did,
"firmwareVersion": target_v,
})
triggered.append((did, disp_name(d), target_v))
if upgrades:
if upgrade_in_flight(d, target_v):
up = d.get("upgrade") or {}
print(f" skip {did} {disp_name(d):<30}: already {up.get('status')}{up.get('firmwareVersion')}")
continue
try:
api("POST", "/tasks", {
"upgrades": upgrades,
"upgrades": [{"deviceId": did, "firmwareVersion": target_v}],
"upgradeInMaintenanceWindow": False,
})
for did, name, tv in triggered:
print(f" trigger OK {did} {name:<30}{tv}")
print(f" trigger OK {did} {disp_name(d):<30}{target_v}")
triggered.append((did, disp_name(d), target_v))
except SystemExit:
print(f" batch trigger FAILED for {len(upgrades)} device(s)", file=sys.stderr)
failed_total += len(upgrades)
triggered = []
print(f" trigger FAIL {did} {disp_name(d):<30}{target_v}", file=sys.stderr)
failed_total += 1
if not args.wait:
# No completion wait — just space batches out so we don't blast
# the whole fleet simultaneously.
if batch_idx + 1 < n_batches:
print(f" sleeping {args.batch_delay}s before next batch...")
time.sleep(args.batch_delay)
continue
# Wait for every device in this batch to land on its target version.
@ -724,14 +733,12 @@ def main() -> None:
pu.add_argument("--all", action="store_true", help="upgrade every device with a newer firmware available")
pu.add_argument("--yes", action="store_true", help="actually trigger upgrades (default: dry-run)")
pu.add_argument("--force", action="store_true", help="upgrade even if already on latest")
pu.add_argument("--batch-size", type=int, default=5,
help="how many devices to upgrade in parallel per batch (default: 5)")
pu.add_argument("--batch-size", type=int, default=0,
help="devices per batch; 0 means all in one batch (default). Only useful with --wait.")
pu.add_argument("--wait", action="store_true",
help="wait for each batch to land on the new firmware before starting the next")
pu.add_argument("--wait-timeout", type=int, default=900,
help="seconds to wait per batch when --wait is set (default: 900)")
pu.add_argument("--batch-delay", type=int, default=30,
help="seconds to sleep between batches when --wait is NOT set (default: 30)")
pu.set_defaults(func=cmd_upgrade)
args = p.parse_args()