# UnifiApi
Elixir HTTP client for **UniFi Dream Machine** APIs, covering both the **Network API** (v10.1.84) and the **Protect API** (v6.2.88). Built on [Req](https://hexdocs.pm/req).
## Installation
Add `unifi_api` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:unifi_api, "~> 0.2.0"}
]
end
```
## Configuration
### Application config
```elixir
# config/config.exs
config :unifi_api,
base_url: "https://192.168.1.1",
api_key: "your-api-key",
verify_ssl: false,
# UDM defaults — for Cloud Key, set both to "/integration"
network_path: "/proxy/network/integration",
protect_path: "/proxy/protect/integration"
```
### Environment variables
```elixir
# config/runtime.exs
config :unifi_api,
base_url: System.get_env("UNIFI_BASE_URL", "https://192.168.1.1"),
api_key: System.get_env("UNIFI_API_KEY", ""),
verify_ssl: System.get_env("UNIFI_VERIFY_SSL", "false") == "true",
network_path: System.get_env("UNIFI_NETWORK_PATH", "/proxy/network/integration"),
protect_path: System.get_env("UNIFI_PROTECT_PATH", "/proxy/protect/integration")
```
### Path prefixes
On **UDM / UDM Pro / UDM SE** (UniFi OS), the API runs behind a reverse proxy:
| API | Default path | Env var |
|-----|-------------|---------|
| Network | `/proxy/network/integration` | `UNIFI_NETWORK_PATH` |
| Protect | `/proxy/protect/integration` | `UNIFI_PROTECT_PATH` |
On **Cloud Key** or standalone controllers, set both to `"/integration"`:
```elixir
config :unifi_api,
network_path: "/integration",
protect_path: "/integration"
```
### Runtime override
Pass options directly when creating a client to override application config:
```elixir
client = UnifiApi.new(
base_url: "https://192.168.0.1",
api_key: "my-api-key",
verify_ssl: false
)
```
## Quick Start
```elixir
# Create a client (uses application config)
client = UnifiApi.new()
# Or with explicit options
client = UnifiApi.new(base_url: "https://192.168.1.1", api_key: "my-key")
# Check the controller version
{:ok, info} = UnifiApi.Network.Info.get_info(client)
# => {:ok, %{"applicationVersion" => "10.1.84"}}
# List all sites
{:ok, sites} = UnifiApi.Network.Sites.list(client)
# List devices on a site
{:ok, devices} = UnifiApi.Network.Devices.list(client, "site-uuid")
# List Protect cameras
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
```
## Network API
All Network API functions require a `site_id` (except `Info`, `Resources.list_dpi_categories/2`, `Resources.list_dpi_applications/2`, `Resources.list_countries/2`, and `Devices.list_pending/2`).
### Sites
```elixir
{:ok, sites} = UnifiApi.Network.Sites.list(client)
# => {:ok, [%{"id" => "abc-123", "name" => "Default", "internalReference" => "default"}]}
```
### Devices
```elixir
# List all devices on a site
{:ok, devices} = UnifiApi.Network.Devices.list(client, site_id)
# Get a specific device
{:ok, device} = UnifiApi.Network.Devices.get(client, site_id, device_id)
# Get latest device statistics
{:ok, stats} = UnifiApi.Network.Devices.get_statistics(client, site_id, device_id)
# Adopt a new device
{:ok, _} = UnifiApi.Network.Devices.adopt(client, site_id, %{mac: "aa:bb:cc:dd:ee:ff"})
# Execute a device action (restart, locate, etc.)
{:ok, _} = UnifiApi.Network.Devices.execute_action(client, site_id, device_id, %{action: "restart"})
# Execute a port action (PoE cycle, etc.)
{:ok, _} = UnifiApi.Network.Devices.execute_port_action(client, site_id, device_id, 3, %{action: "cycle"})
# Remove a device
{:ok, _} = UnifiApi.Network.Devices.remove(client, site_id, device_id)
# List pending devices (not site-scoped)
{:ok, pending} = UnifiApi.Network.Devices.list_pending(client)
```
### Clients
```elixir
# List connected clients
{:ok, clients} = UnifiApi.Network.Clients.list(client, site_id)
# Each client has: type (WIRED/WIRELESS/VPN/TELEPORT), id, name, connectedAt, ipAddress, access
```
### Networks
```elixir
# List all networks
{:ok, networks} = UnifiApi.Network.Networks.list(client, site_id)
# Get a specific network
{:ok, network} = UnifiApi.Network.Networks.get(client, site_id, network_id)
# Create a network
{:ok, network} = UnifiApi.Network.Networks.create(client, site_id, %{
name: "Guest VLAN",
vlanId: 100
})
# Update a network
{:ok, _} = UnifiApi.Network.Networks.update(client, site_id, network_id, %{name: "New Name"})
# Delete a network
{:ok, _} = UnifiApi.Network.Networks.delete(client, site_id, network_id)
```
### WiFi
```elixir
# List WiFi broadcasts (SSIDs)
{:ok, ssids} = UnifiApi.Network.Wifi.list(client, site_id)
```
### Firewall
```elixir
# --- Zones ---
{:ok, zones} = UnifiApi.Network.Firewall.list_zones(client, site_id)
{:ok, zone} = UnifiApi.Network.Firewall.get_zone(client, site_id, zone_id)
{:ok, zone} = UnifiApi.Network.Firewall.create_zone(client, site_id, %{name: "DMZ", networkIds: [net_id]})
{:ok, _} = UnifiApi.Network.Firewall.update_zone(client, site_id, zone_id, %{name: "DMZ-Updated"})
{:ok, _} = UnifiApi.Network.Firewall.delete_zone(client, site_id, zone_id)
# --- Policies ---
{:ok, policies} = UnifiApi.Network.Firewall.list_policies(client, site_id)
{:ok, policy} = UnifiApi.Network.Firewall.get_policy(client, site_id, policy_id)
{:ok, policy} = UnifiApi.Network.Firewall.create_policy(client, site_id, %{
name: "Block IoT to LAN",
enabled: true,
action: "BLOCK",
source: %{zoneId: iot_zone_id},
destination: %{zoneId: lan_zone_id}
})
{:ok, _} = UnifiApi.Network.Firewall.update_policy(client, site_id, policy_id, %{enabled: false})
{:ok, _} = UnifiApi.Network.Firewall.delete_policy(client, site_id, policy_id)
```
### Hotspot Vouchers
```elixir
# List all vouchers
{:ok, vouchers} = UnifiApi.Network.Hotspot.list_vouchers(client, site_id)
# Get a specific voucher
{:ok, voucher} = UnifiApi.Network.Hotspot.get_voucher(client, site_id, voucher_id)
# Create vouchers (1-1000 at a time)
{:ok, vouchers} = UnifiApi.Network.Hotspot.create_vouchers(client, site_id, %{
count: 10,
name: "Event Pass",
timeLimitMinutes: 1440,
authorizedGuestLimit: 1,
dataUsageLimitMBytes: 500,
rxRateLimitKbps: 5000,
txRateLimitKbps: 1000
})
# Delete a specific voucher
{:ok, _} = UnifiApi.Network.Hotspot.delete_voucher(client, site_id, voucher_id)
# Delete all vouchers
{:ok, _} = UnifiApi.Network.Hotspot.delete_vouchers(client, site_id)
```
### ACL Rules
```elixir
# List ACL rules
{:ok, rules} = UnifiApi.Network.ACL.list(client, site_id)
# Create an ACL rule
{:ok, rule} = UnifiApi.Network.ACL.create(client, site_id, %{
type: "IPV4",
name: "Block SSH",
enabled: true,
action: "BLOCK",
protocolFilter: %{protocol: "TCP", dstPort: 22}
})
# Update and delete
{:ok, _} = UnifiApi.Network.ACL.update(client, site_id, rule_id, %{enabled: false})
{:ok, _} = UnifiApi.Network.ACL.delete(client, site_id, rule_id)
# Manage rule ordering
{:ok, ordering} = UnifiApi.Network.ACL.get_ordering(client, site_id)
{:ok, _} = UnifiApi.Network.ACL.update_ordering(client, site_id, %{ids: ["rule-1", "rule-2"]})
```
### DNS Policies
```elixir
# List DNS policies
{:ok, policies} = UnifiApi.Network.DNS.list(client, site_id)
# Create a DNS record
{:ok, policy} = UnifiApi.Network.DNS.create(client, site_id, %{
type: "A_RECORD",
name: "app.local",
value: "192.168.1.50"
})
# Supported types: A_RECORD, AAAA_RECORD, CNAME_RECORD, MX_RECORD,
# TXT_RECORD, SRV_RECORD, FORWARD_DOMAIN
```
### Traffic Matching
```elixir
{:ok, lists} = UnifiApi.Network.TrafficMatching.list(client, site_id)
# Types: PORTS, IPV4_ADDRESSES, IPV6_ADDRESSES
```
### Supporting Resources
```elixir
# WAN interfaces
{:ok, wans} = UnifiApi.Network.Resources.list_wans(client, site_id)
# VPN
{:ok, tunnels} = UnifiApi.Network.Resources.list_vpn_tunnels(client, site_id)
{:ok, servers} = UnifiApi.Network.Resources.list_vpn_servers(client, site_id)
# RADIUS
{:ok, profiles} = UnifiApi.Network.Resources.list_radius_profiles(client, site_id)
# Device tags
{:ok, tags} = UnifiApi.Network.Resources.list_device_tags(client, site_id)
# DPI (not site-scoped)
{:ok, categories} = UnifiApi.Network.Resources.list_dpi_categories(client)
{:ok, apps} = UnifiApi.Network.Resources.list_dpi_applications(client)
# Countries (not site-scoped)
{:ok, countries} = UnifiApi.Network.Resources.list_countries(client)
```
## Protect API
Protect endpoints are **not** site-scoped.
### Cameras
```elixir
# List all cameras
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
# Get a specific camera
{:ok, camera} = UnifiApi.Protect.Cameras.get(client, camera_id)
# Update camera settings
{:ok, _} = UnifiApi.Protect.Cameras.update(client, camera_id, %{
name: "Front Door",
micVolume: 80,
videoMode: "highFps",
ledSettings: %{isEnabled: false}
})
# Take a snapshot (returns JPEG binary)
{:ok, jpeg} = UnifiApi.Protect.Cameras.snapshot(client, camera_id)
File.write!("snapshot.jpg", jpeg)
# High quality snapshot
{:ok, jpeg} = UnifiApi.Protect.Cameras.snapshot(client, camera_id, high_quality: true)
# PTZ controls
{:ok, _} = UnifiApi.Protect.Cameras.ptz_goto(client, camera_id, 1) # Go to preset slot 1
{:ok, _} = UnifiApi.Protect.Cameras.ptz_patrol_start(client, camera_id, 0) # Start patrol slot 0
{:ok, _} = UnifiApi.Protect.Cameras.ptz_patrol_stop(client, camera_id) # Stop patrol
```
### NVR
```elixir
{:ok, nvr} = UnifiApi.Protect.NVR.get(client)
# => {:ok, %{"id" => "...", "name" => "UNVR", "doorbellSettings" => %{...}}}
```
### Viewers
```elixir
{:ok, viewers} = UnifiApi.Protect.Viewers.list(client)
{:ok, viewer} = UnifiApi.Protect.Viewers.get(client, viewer_id)
{:ok, _} = UnifiApi.Protect.Viewers.update(client, viewer_id, %{liveview: liveview_id})
```
### Liveviews
```elixir
{:ok, liveviews} = UnifiApi.Protect.Liveviews.list(client)
# Each has: id, name, isDefault, isGlobal, owner, layout (1-26), slots
```
### Sensors
```elixir
{:ok, sensors} = UnifiApi.Protect.Sensors.list(client)
# Each has: id, name, state, mountType, batteryStatus, stats,
# isOpened, isMotionDetected, temperature/humidity/light/leak settings
```
### Lights
```elixir
{:ok, lights} = UnifiApi.Protect.Lights.list(client)
# Each has: id, name, state, isDark, isLightOn, lastMotion,
# lightModeSettings, lightDeviceSettings, camera
```
### Chimes
```elixir
{:ok, chimes} = UnifiApi.Protect.Chimes.list(client)
# Each has: id, name, state, cameraIds, ringSettings
```
## Streaming & Pagination
Every list endpoint has a `stream` variant that returns a lazy `Stream` powered by
`Stream.resource/3`. Pages are fetched on demand — no data is pulled until you
consume the stream with `Enum` or `Stream` functions.
### Lazy streaming (recommended)
```elixir
# Stream ALL devices across pages — fetches 200 per page automatically
UnifiApi.Network.Devices.stream(client, site_id)
|> Enum.to_list()
# Only the first page is fetched
UnifiApi.Network.Devices.stream(client, site_id)
|> Enum.take(5)
# Filter + stream — composable with the full Stream/Enum API
UnifiApi.Network.Clients.stream(client, site_id, filter: "type.eq(WIRELESS)")
|> Stream.map(& &1["name"])
|> Enum.to_list()
# Count all clients without loading them all into memory at once
UnifiApi.Network.Clients.stream(client, site_id)
|> Enum.count()
# Custom page size
UnifiApi.Network.Devices.stream(client, site_id, limit: 50)
|> Enum.to_list()
# Stream firewall policies, vouchers, ACL rules, DNS, etc.
UnifiApi.Network.Firewall.stream_policies(client, site_id)
|> Stream.filter(& &1["enabled"])
|> Enum.to_list()
UnifiApi.Network.Hotspot.stream_vouchers(client, site_id)
|> Stream.reject(& &1["expired"])
|> Enum.map(& &1["code"])
UnifiApi.Network.Resources.stream_dpi_categories(client)
|> Enum.to_list()
```
Stream functions raise on API errors, making them safe to compose in pipelines.
### Available stream functions
| Module | Function |
|--------|----------|
| Sites | `stream/2` |
| Devices | `stream/3`, `stream_pending/2` |
| Clients | `stream/3` |
| Networks | `stream/3` |
| Wifi | `stream/3` |
| Firewall | `stream_zones/3`, `stream_policies/3` |
| Hotspot | `stream_vouchers/3` |
| ACL | `stream/3` |
| DNS | `stream/3` |
| TrafficMatching | `stream/3` |
| Resources | `stream_wans/3`, `stream_vpn_tunnels/3`, `stream_vpn_servers/3`, `stream_radius_profiles/3`, `stream_device_tags/3`, `stream_dpi_categories/2`, `stream_dpi_applications/2`, `stream_countries/2` |
### Manual pagination
If you need per-page control, use `list` with `:offset` and `:limit`:
```elixir
{:ok, page1} = UnifiApi.Network.Devices.list(client, site_id, limit: 50, offset: 0)
{:ok, page2} = UnifiApi.Network.Devices.list(client, site_id, limit: 50, offset: 50)
# Filter (UniFi filter expression syntax)
{:ok, wireless} = UnifiApi.Network.Clients.list(client, site_id,
filter: "type.eq(WIRELESS)"
)
```
### Filter syntax
Filters use the format `property.function(args)` and can be combined:
| Function | Example |
|----------|---------|
| `eq` | `name.eq(Office)` |
| `ne` | `type.ne(WIRELESS)` |
| `gt`, `ge`, `lt`, `le` | `connectedAt.gt(1700000000)` |
| `in`, `notIn` | `type.in(WIRED,VPN)` |
| `like` | `name.like(cam*)` |
| `isNull`, `isNotNull` | `ipAddress.isNotNull()` |
| `isEmpty` | `name.isEmpty()` |
| `contains`, `containsAny`, `containsAll`, `containsExactly` | `tags.contains(vip)` |
Combine with `and()`, `or()`, `not()`.
## Data Extraction Recipes
Common patterns for pulling structured data out of your UniFi controller.
### Export all clients to a list of maps
```elixir
client = UnifiApi.new()
all_clients =
UnifiApi.Network.Sites.stream(client)
|> Enum.flat_map(fn site ->
UnifiApi.Network.Clients.stream(client, site["id"])
|> Enum.map(&Map.put(&1, "site", site["name"]))
end)
# Filter only wireless clients
wireless = Enum.filter(all_clients, &(&1["type"] == "WIRELESS"))
```
### Build a device inventory CSV
```elixir
client = UnifiApi.new()
rows =
UnifiApi.Network.Sites.stream(client)
|> Enum.flat_map(fn site ->
UnifiApi.Network.Devices.stream(client, site["id"])
|> Enum.map(fn device ->
[site["name"], device["name"], device["mac"], device["model"], device["state"]]
|> Enum.join(",")
end)
end)
csv = ["site,name,mac,model,state" | rows] |> Enum.join("\n")
File.write!("devices.csv", csv)
```
### Scrape all camera snapshots
```elixir
client = UnifiApi.new()
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
for camera <- cameras, camera["state"] == "CONNECTED" do
case UnifiApi.Protect.Cameras.snapshot(client, camera["id"], high_quality: true) do
{:ok, jpeg} ->
name = camera["name"] |> String.replace(~r/[^\w]/, "_")
File.write!("snapshots/#{name}.jpg", jpeg)
{:error, reason} ->
IO.puts("Failed #{camera["name"]}: #{inspect(reason)}")
end
end
```
### Collect network topology (sites, networks, devices)
```elixir
client = UnifiApi.new()
topology =
UnifiApi.Network.Sites.stream(client)
|> Enum.map(fn site ->
sid = site["id"]
%{
site: site["name"],
networks:
UnifiApi.Network.Networks.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "vlanId", "subnet"])),
devices:
UnifiApi.Network.Devices.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "mac", "model", "state"]))
}
end)
```
### Monitor connected client count over time
```elixir
client = UnifiApi.new()
[site | _] = UnifiApi.Network.Sites.stream(client) |> Enum.take(1)
# Poll every 60 seconds
Stream.interval(60_000)
|> Stream.map(fn _ ->
counts =
UnifiApi.Network.Clients.stream(client, site["id"])
|> Enum.group_by(& &1["type"])
|> Map.new(fn {type, list} -> {type, length(list)} end)
{DateTime.utc_now(), counts}
end)
|> Stream.each(fn {time, counts} ->
IO.puts("#{time} | WIRED=#{counts["WIRED"] || 0} WIRELESS=#{counts["WIRELESS"] || 0} VPN=#{counts["VPN"] || 0}")
end)
|> Stream.run()
```
### Export firewall rules
```elixir
client = UnifiApi.new()
UnifiApi.Network.Sites.stream(client)
|> Enum.map(fn site ->
sid = site["id"]
%{
site: site["name"],
zones:
UnifiApi.Network.Firewall.stream_zones(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "networkIds"])),
policies:
UnifiApi.Network.Firewall.stream_policies(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "enabled", "action", "source", "destination"]))
}
end)
```
### Export hotspot voucher codes
```elixir
client = UnifiApi.new()
[site | _] = UnifiApi.Network.Sites.stream(client) |> Enum.take(1)
active =
UnifiApi.Network.Hotspot.stream_vouchers(client, site["id"])
|> Stream.reject(& &1["expired"])
|> Enum.map(&Map.take(&1, ["code", "name", "timeLimitMinutes", "expiresAt"]))
# Print as a table
for v <- active do
IO.puts("#{v["code"]} #{v["name"]} #{v["timeLimitMinutes"]}min")
end
```
### Dump all Protect device info
```elixir
client = UnifiApi.new()
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
{:ok, sensors} = UnifiApi.Protect.Sensors.list(client)
{:ok, lights} = UnifiApi.Protect.Lights.list(client)
{:ok, chimes} = UnifiApi.Protect.Chimes.list(client)
{:ok, nvr} = UnifiApi.Protect.NVR.get(client)
protect_inventory = %{
nvr: Map.take(nvr, ["id", "name", "modelKey"]),
cameras: Enum.map(cameras, &Map.take(&1, ["id", "name", "state", "mac", "modelKey"])),
sensors: Enum.map(sensors, &Map.take(&1, ["id", "name", "state", "batteryStatus"])),
lights: Enum.map(lights, &Map.take(&1, ["id", "name", "state", "isLightOn"])),
chimes: Enum.map(chimes, &Map.take(&1, ["id", "name", "state"]))
}
```
### Dashboard data scraper
Pull everything you need for a custom dashboard in one shot — network overview,
client breakdown, device health, WiFi status, and Protect camera states.
```elixir
client = UnifiApi.new()
# Get controller info
{:ok, info} = UnifiApi.Network.Info.get_info(client)
# Collect per-site data
sites_data =
UnifiApi.Network.Sites.stream(client)
|> Enum.map(fn site ->
sid = site["id"]
# Clients grouped by type
clients = UnifiApi.Network.Clients.stream(client, sid) |> Enum.to_list()
client_breakdown =
clients
|> Enum.group_by(& &1["type"])
|> Map.new(fn {type, list} -> {type, length(list)} end)
# Devices with health status
devices =
UnifiApi.Network.Devices.stream(client, sid)
|> Enum.map(fn d ->
%{
name: d["name"],
mac: d["mac"],
model: d["model"],
state: d["state"],
ip: d["ip"]
}
end)
connected_devices = Enum.count(devices, & &1.state == "CONNECTED")
disconnected_devices = Enum.count(devices, & &1.state == "DISCONNECTED")
# Networks
networks =
UnifiApi.Network.Networks.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "vlanId"]))
# WiFi SSIDs
ssids =
UnifiApi.Network.Wifi.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "enabled"]))
# WANs
wans =
UnifiApi.Network.Resources.stream_wans(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "status"]))
%{
site_id: sid,
site_name: site["name"],
clients: %{
total: length(clients),
wired: client_breakdown["WIRED"] || 0,
wireless: client_breakdown["WIRELESS"] || 0,
vpn: client_breakdown["VPN"] || 0,
teleport: client_breakdown["TELEPORT"] || 0
},
devices: %{
total: length(devices),
connected: connected_devices,
disconnected: disconnected_devices,
list: devices
},
networks: networks,
ssids: ssids,
wans: wans
}
end)
# Protect overview
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
{:ok, sensors} = UnifiApi.Protect.Sensors.list(client)
{:ok, lights} = UnifiApi.Protect.Lights.list(client)
{:ok, nvr} = UnifiApi.Protect.NVR.get(client)
protect_data = %{
nvr: Map.take(nvr, ["id", "name", "modelKey"]),
cameras: %{
total: length(cameras),
connected: Enum.count(cameras, & &1["state"] == "CONNECTED"),
list:
Enum.map(cameras, fn c ->
%{
id: c["id"],
name: c["name"],
state: c["state"],
model: c["modelKey"]
}
end)
},
sensors: %{
total: length(sensors),
open_doors: Enum.count(sensors, & &1["isOpened"]),
motion_detected: Enum.count(sensors, & &1["isMotionDetected"])
},
lights: %{
total: length(lights),
on: Enum.count(lights, & &1["isLightOn"])
}
}
# Full dashboard payload
dashboard = %{
controller_version: info["applicationVersion"],
scraped_at: DateTime.utc_now(),
sites: sites_data,
protect: protect_data
}
# Write to JSON
File.write!("dashboard.json", Jason.encode!(dashboard, pretty: true))
```
You can run this on an interval to feed a time-series database, or serve it
from a Phoenix endpoint for a live dashboard:
```elixir
# Poll every 30 seconds and write fresh data
Stream.interval(30_000)
|> Stream.each(fn _ ->
# ... same scraper logic above ...
File.write!("dashboard.json", Jason.encode!(dashboard, pretty: true))
IO.puts("[#{DateTime.utc_now()}] Dashboard updated")
end)
|> Stream.run()
```
## Formatted Output
`UnifiApi.Formatter` prints API responses as colored ANSI tables in iex.
### Quick shortcuts
```elixir
{:ok, sites} = UnifiApi.Network.Sites.list(client)
UnifiApi.Formatter.sites(sites)
{:ok, devices} = UnifiApi.Network.Devices.list(client, site_id)
UnifiApi.Formatter.devices(devices)
# State column is color-coded: green=CONNECTED, yellow=CONNECTING, red=DISCONNECTED
{:ok, clients} = UnifiApi.Network.Clients.list(client, site_id)
UnifiApi.Formatter.clients(clients)
# Type column is color-coded: blue=WIRED, magenta=WIRELESS, cyan=VPN
{:ok, cameras} = UnifiApi.Protect.Cameras.list(protect)
UnifiApi.Formatter.cameras(cameras)
{:ok, networks} = UnifiApi.Network.Networks.list(client, site_id)
UnifiApi.Formatter.networks(networks)
```
### Custom tables
```elixir
# Pick any columns
{:ok, devices} = UnifiApi.Network.Devices.list(client, site_id)
UnifiApi.Formatter.table(devices, ["name", "mac", "model", "state", "ip"],
title: "My Devices",
colors: %{"state" => :state}
)
# Detail view for a single record
{:ok, nvr} = UnifiApi.Protect.NVR.get(protect)
UnifiApi.Formatter.detail(nvr, title: "NVR Info")
```
## Error Handling
All functions return `{:ok, body}` on success or `{:error, reason}` on failure:
```elixir
case UnifiApi.Network.Devices.get(client, site_id, "bad-id") do
{:ok, device} ->
IO.inspect(device)
{:error, {404, body}} ->
IO.puts("Not found: #{inspect(body)}")
{:error, {401, _}} ->
IO.puts("Invalid API key")
{:error, reason} ->
IO.puts("Connection error: #{inspect(reason)}")
end
```
## Generating Docs
```bash
mix deps.get
mix docs
open doc/index.html
```
## License
Apache License 2.0 — see [LICENSE](LICENSE) for details.