├── .travis.yml ├── README.md ├── sites.go ├── sites_test.go ├── LICENSE.md ├── alarms.go ├── alarms_test.go ├── stations_test.go ├── stations.go ├── client.go ├── client_test.go ├── devices_test.go └── devices.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.7 4 | before_install: 5 | - go get github.com/axw/gocov/gocov 6 | - go get github.com/mattn/goveralls 7 | - go get golang.org/x/tools/cmd/cover 8 | - go get github.com/golang/lint/golint 9 | before_script: 10 | - go get -d ./... 11 | script: 12 | - golint ./... 13 | - go vet ./... 14 | - go test -v ./... 15 | - if ! $HOME/gopath/bin/goveralls -service=travis-ci -repotoken $COVERALLS_TOKEN; then echo "Coveralls not available."; fi 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | unifi [![GoDoc](http://godoc.org/github.com/mdlayher/unifi?status.svg)](http://godoc.org/github.com/mdlayher/unifi) [![Build Status](https://travis-ci.org/mdlayher/unifi.svg?branch=master)](https://travis-ci.org/mdlayher/unifi) [![Coverage Status](https://coveralls.io/repos/mdlayher/unifi/badge.svg?branch=master)](https://coveralls.io/r/mdlayher/unifi?branch=master) [![Report Card](http://goreportcard.com/badge/mdlayher/unifi)](http://goreportcard.com/report/mdlayher/unifi) 2 | ===== 3 | 4 | Package `unifi` implements a client for the Ubiquiti UniFi Controller v4 and v5 API. 5 | MIT Licensed. 6 | -------------------------------------------------------------------------------- /sites.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | // A Site is a physical location with UniFi devices managed by a UniFi 4 | // Controller. 5 | type Site struct { 6 | ID string `json:"_id"` 7 | Description string `json:"desc"` 8 | Name string `json:"name"` 9 | NumAPs int `json:"num_ap"` 10 | NumStations int `json:"num_sta"` 11 | Role string `json:"role"` 12 | } 13 | 14 | // Sites returns all of the Sites managed by a UniFi Controller. 15 | func (c *Client) Sites() ([]*Site, error) { 16 | var v struct { 17 | Sites []*Site `json:"data"` 18 | } 19 | 20 | req, err := c.newRequest( 21 | "GET", 22 | "/api/self/sites", 23 | nil, 24 | ) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | _, err = c.do(req, &v) 30 | return v.Sites, err 31 | } 32 | -------------------------------------------------------------------------------- /sites_test.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestClientSites(t *testing.T) { 10 | wantSite := &Site{ 11 | Name: "default", 12 | Description: "Company", 13 | } 14 | 15 | v := struct { 16 | Sites []*Site `json:"data"` 17 | }{ 18 | Sites: []*Site{wantSite}, 19 | } 20 | 21 | c, done := testClient(t, testHandler(t, http.MethodGet, "/api/self/sites", nil, v)) 22 | defer done() 23 | 24 | sites, err := c.Sites() 25 | if err != nil { 26 | t.Fatalf("unexpected error from Client.Sites: %v", err) 27 | } 28 | 29 | if want, got := 1, len(sites); want != got { 30 | t.Fatalf("unexpected number of Sites:\n- want: %d\n- got: %d", 31 | want, got) 32 | } 33 | 34 | if want, got := wantSite, sites[0]; !reflect.DeepEqual(want, got) { 35 | t.Fatalf("unexpected Site:\n- want: %v\n- got: %v", 36 | want, got) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | =========== 3 | 4 | Copyright (C) 2016 Matt Layher 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /alarms.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "time" 8 | ) 9 | 10 | // Alarms returns all of the Alarms for a specified site name. 11 | func (c *Client) Alarms(siteName string) ([]*Alarm, error) { 12 | var v struct { 13 | Alarms []*Alarm `json:"data"` 14 | } 15 | 16 | req, err := c.newRequest( 17 | "GET", 18 | fmt.Sprintf("/api/s/%s/list/alarm", siteName), 19 | nil, 20 | ) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | _, err = c.do(req, &v) 26 | return v.Alarms, err 27 | } 28 | 29 | // An Alarm is an alert which is triggered when a Device becomes 30 | // unavailable. 31 | type Alarm struct { 32 | ID string 33 | APMAC net.HardwareAddr 34 | APName string 35 | Archived bool 36 | DateTime time.Time 37 | Key string 38 | Message string 39 | SiteID string 40 | Subsystem string 41 | } 42 | 43 | // UnmarshalJSON unmarshals the raw JSON representation of an Alarm. 44 | func (a *Alarm) UnmarshalJSON(b []byte) error { 45 | var al alarm 46 | if err := json.Unmarshal(b, &al); err != nil { 47 | return err 48 | } 49 | 50 | mac, err := net.ParseMAC(al.AP) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | t, err := time.Parse(time.RFC3339, al.DateTime) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | *a = Alarm{ 61 | ID: al.ID, 62 | APMAC: mac, 63 | APName: al.APName, 64 | Archived: al.Archived, 65 | DateTime: t, 66 | Key: al.Key, 67 | Message: al.Msg, 68 | SiteID: al.SiteID, 69 | Subsystem: al.Subsystem, 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // An alarm is the raw structure of an Alarm returned from the UniFi Controller 76 | // API. 77 | type alarm struct { 78 | ID string `json:"_id"` 79 | AP string `json:"ap"` 80 | APName string `json:"ap_name"` 81 | Archived bool `json:"archived"` 82 | DateTime string `json:"datetime"` 83 | Key string `json:"key"` 84 | Msg string `json:"msg"` 85 | SiteID string `json:"site_id"` 86 | Subsystem string `json:"subsystem"` 87 | // A UNIX timestamp field "time" exists here, but seems 88 | // redundant with DateTime 89 | } 90 | -------------------------------------------------------------------------------- /alarms_test.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestClientAlarms(t *testing.T) { 16 | const ( 17 | wantSite = "default" 18 | wantID = "abcdef123457890" 19 | wantAPName = "ap001" 20 | wantMessage = "ap001 was disconnected" 21 | ) 22 | var ( 23 | wantAPMAC = net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad} 24 | wantDateTime = time.Date(2016, time.January, 01, 0, 0, 0, 0, time.UTC) 25 | ) 26 | 27 | wantAlarm := &Alarm{ 28 | ID: wantID, 29 | APMAC: wantAPMAC, 30 | APName: wantAPName, 31 | DateTime: wantDateTime, 32 | Message: wantMessage, 33 | } 34 | 35 | v := struct { 36 | Alarms []alarm `json:"data"` 37 | }{ 38 | Alarms: []alarm{{ 39 | ID: wantID, 40 | AP: wantAPMAC.String(), 41 | APName: wantAPName, 42 | DateTime: wantDateTime.Format(time.RFC3339), 43 | Msg: wantMessage, 44 | }}, 45 | } 46 | 47 | c, done := testClient(t, testHandler( 48 | t, 49 | http.MethodGet, 50 | fmt.Sprintf("/api/s/%s/list/alarm", wantSite), 51 | nil, 52 | v, 53 | )) 54 | defer done() 55 | 56 | alarms, err := c.Alarms(wantSite) 57 | if err != nil { 58 | t.Fatalf("unexpected error from Client.Alarms: %v", err) 59 | } 60 | 61 | if want, got := 1, len(alarms); want != got { 62 | t.Fatalf("unexpected number of Alarms:\n- want: %d\n- got: %d", 63 | want, got) 64 | } 65 | 66 | if want, got := wantAlarm, alarms[0]; !reflect.DeepEqual(want, got) { 67 | t.Fatalf("unexpected Alarm:\n- want: %#v\n- got: %#v", 68 | want, got) 69 | } 70 | } 71 | 72 | func TestAlarmUnmarshalJSON(t *testing.T) { 73 | var tests = []struct { 74 | desc string 75 | b []byte 76 | a *Alarm 77 | err error 78 | }{ 79 | { 80 | desc: "invalid JSON", 81 | b: []byte(`<>`), 82 | err: errors.New("invalid character"), 83 | }, 84 | { 85 | desc: "invalid APMAC", 86 | b: []byte(`{"ap":"foo"}`), 87 | err: errors.New("invalid MAC address"), 88 | }, 89 | { 90 | desc: "invalid DateTime", 91 | b: []byte(`{"ap":"de:ad:be:ef:de:ad","datetime":"foo"}`), 92 | err: errors.New("parsing time"), 93 | }, 94 | { 95 | desc: "OK", 96 | b: bytes.TrimSpace([]byte(` 97 | { 98 | "_id": "abcdef1234567890", 99 | "ap": "de:ad:be:ef:de:ad", 100 | "ap_name": "ap001", 101 | "archived": false, 102 | "datetime": "2016-01-01T00:00:00Z" 103 | "key": "EVT AP Lost Contact", 104 | "msg": "ap001 was disconnected" 105 | "site_id": "default", 106 | "subsystem": "wlan" 107 | } 108 | `)), 109 | a: &Alarm{ 110 | ID: "abcdef1234567890", 111 | APMAC: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad}, 112 | APName: "ap001", 113 | DateTime: time.Date(2016, time.January, 01, 0, 0, 0, 0, time.UTC), 114 | Key: "EVT AP Lost Contact", 115 | Message: "ap001 was disconnected", 116 | SiteID: "default", 117 | Subsystem: "wlan", 118 | }, 119 | }, 120 | } 121 | 122 | for _, tt := range tests { 123 | t.Run(tt.desc, func(t *testing.T) { 124 | a := new(Alarm) 125 | err := a.UnmarshalJSON(tt.b) 126 | if want, got := errStr(tt.err), errStr(err); !strings.Contains(got, want) { 127 | t.Fatalf("unexpected error:\n- want: %v\n- got: %v", 128 | want, got) 129 | } 130 | if err != nil { 131 | return 132 | } 133 | 134 | if want, got := tt.a, a; !reflect.DeepEqual(got, want) { 135 | t.Fatalf("unexpected Alarm:\n- want: %+v\n- got: %+v", 136 | want, got) 137 | } 138 | }) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /stations_test.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestClientStations(t *testing.T) { 16 | const ( 17 | wantSite = "default" 18 | wantID = "abcdef123457890" 19 | wantHostname = "somehost" 20 | ) 21 | var ( 22 | wantStationMAC = net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad} 23 | wantIP = net.IPv4(192, 168, 1, 2) 24 | wantMAC = net.HardwareAddr{0xab, 0xad, 0x1d, 0xea, 0xab, 0xad} 25 | ) 26 | 27 | zeroUNIX := time.Unix(0, 0) 28 | 29 | wantStation := &Station{ 30 | ID: wantID, 31 | APMAC: wantStationMAC, 32 | AssociationTime: zeroUNIX, 33 | FirstSeen: zeroUNIX, 34 | Hostname: wantHostname, 35 | IP: wantIP, 36 | LastSeen: zeroUNIX, 37 | MAC: wantMAC, 38 | SiteID: wantSite, 39 | Stats: &StationStats{}, 40 | } 41 | 42 | v := struct { 43 | Stations []station `json:"data"` 44 | }{ 45 | Stations: []station{{ 46 | ID: wantID, 47 | ApMac: wantStationMAC.String(), 48 | Hostname: wantHostname, 49 | IP: wantIP.String(), 50 | Mac: wantMAC.String(), 51 | SiteID: wantSite, 52 | }}, 53 | } 54 | 55 | c, done := testClient(t, testHandler( 56 | t, 57 | http.MethodGet, 58 | fmt.Sprintf("/api/s/%s/stat/sta", wantSite), 59 | nil, 60 | v, 61 | )) 62 | defer done() 63 | 64 | stations, err := c.Stations(wantSite) 65 | if err != nil { 66 | t.Fatalf("unexpected error from Client.Stations: %v", err) 67 | } 68 | 69 | if want, got := 1, len(stations); want != got { 70 | t.Fatalf("unexpected number of Stations:\n- want: %d\n- got: %d", 71 | want, got) 72 | } 73 | 74 | if want, got := wantStation, stations[0]; !reflect.DeepEqual(want, got) { 75 | t.Fatalf("unexpected Station:\n- want: %#v\n- got: %#v", 76 | want, got) 77 | } 78 | } 79 | 80 | func TestStationUnmarshalJSON(t *testing.T) { 81 | zeroUNIX := time.Unix(0, 0) 82 | 83 | var tests = []struct { 84 | desc string 85 | b []byte 86 | s *Station 87 | err error 88 | }{ 89 | { 90 | desc: "invalid JSON", 91 | b: []byte(`<>`), 92 | err: errors.New("invalid character"), 93 | }, 94 | { 95 | desc: "invalid AP MAC", 96 | b: []byte(`{"ap_mac":"foo"}`), 97 | err: errors.New("invalid MAC address"), 98 | }, 99 | { 100 | desc: "invalid MAC", 101 | b: []byte(`{"ap_mac":"de:ad:be:ef:de:ad","mac":"foo"}`), 102 | err: errors.New("invalid MAC address"), 103 | }, 104 | { 105 | desc: "OK", 106 | b: bytes.TrimSpace([]byte(` 107 | { 108 | "_id": "abcdef1234567890", 109 | "ap_mac": "ab:ad:1d:ea:ab:ad", 110 | "channel": 1, 111 | "hostname": "somehost", 112 | "ip": "192.168.1.2", 113 | "is_wired": "true", 114 | "mac": "de:ad:be:ef:de:ad", 115 | "name": "somename", 116 | "noise": -110, 117 | "roam_count": 1, 118 | "rssi": 40, 119 | "site_id": "somesite", 120 | "rx_bytes": 80, 121 | "rx_packets": 4, 122 | "rx_rate": 1024, 123 | "tx_bytes": 20, 124 | "tx_packets": 1, 125 | "tx_power": 10, 126 | "tx_rate": 1024, 127 | "uptime": 10, 128 | "user_id": "someuser" 129 | } 130 | `)), 131 | s: &Station{ 132 | ID: "abcdef1234567890", 133 | APMAC: net.HardwareAddr{0xab, 0xad, 0x1d, 0xea, 0xab, 0xad}, 134 | AssociationTime: zeroUNIX, 135 | Channel: 1, 136 | FirstSeen: zeroUNIX, 137 | Hostname: "somehost", 138 | IP: net.IPv4(192, 168, 1, 2), 139 | IsWired: true, 140 | LastSeen: zeroUNIX, 141 | MAC: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad}, 142 | Name: "somename", 143 | Noise: -110, 144 | RoamCount: 1, 145 | RSSI: 40, 146 | SiteID: "somesite", 147 | Stats: &StationStats{ 148 | ReceiveBytes: 80, 149 | ReceivePackets: 4, 150 | ReceiveRate: 1024, 151 | TransmitBytes: 20, 152 | TransmitPackets: 1, 153 | TransmitPower: 10, 154 | TransmitRate: 1024, 155 | }, 156 | Uptime: 10 * time.Second, 157 | UserID: "someuser", 158 | }, 159 | }, 160 | } 161 | 162 | for _, tt := range tests { 163 | t.Run(tt.desc, func(t *testing.T) { 164 | s := new(Station) 165 | err := s.UnmarshalJSON(tt.b) 166 | if want, got := errStr(tt.err), errStr(err); !strings.Contains(got, want) { 167 | t.Fatalf("unexpected error:\n- want: %v\n- got: %v", 168 | want, got) 169 | } 170 | if err != nil { 171 | return 172 | } 173 | 174 | if want, got := tt.s, s; !reflect.DeepEqual(got, want) { 175 | t.Fatalf("unexpected Station:\n- want: %+v\n- got: %+v", 176 | want, got) 177 | } 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /stations.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "time" 8 | ) 9 | 10 | // Stations returns all of the Stations for a specified site name. 11 | func (c *Client) Stations(siteName string) ([]*Station, error) { 12 | var v struct { 13 | Stations []*Station `json:"data"` 14 | } 15 | 16 | req, err := c.newRequest( 17 | "GET", 18 | fmt.Sprintf("/api/s/%s/stat/sta", siteName), 19 | nil, 20 | ) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | _, err = c.do(req, &v) 26 | return v.Stations, err 27 | } 28 | 29 | // A Station is a client connected to a UniFi access point. 30 | type Station struct { 31 | ID string 32 | APMAC net.HardwareAddr 33 | AssociationTime time.Time 34 | Channel int 35 | FirstSeen time.Time 36 | Hostname string // Device-provided name 37 | IdleTime time.Duration 38 | IP net.IP 39 | IsWired bool 40 | LastSeen time.Time 41 | MAC net.HardwareAddr 42 | RoamCount int 43 | Name string // Unifi-set name 44 | Noise int 45 | RSSI int 46 | SiteID string 47 | Stats *StationStats 48 | Uptime time.Duration 49 | UserID string 50 | } 51 | 52 | // StationStats contains station network activity statistics. 53 | type StationStats struct { 54 | ReceiveBytes int64 55 | ReceivePackets int64 56 | ReceiveRate int 57 | TransmitBytes int64 58 | TransmitPackets int64 59 | TransmitPower int 60 | TransmitRate int 61 | } 62 | 63 | // UnmarshalJSON unmarshals the raw JSON representation of a Station. 64 | func (s *Station) UnmarshalJSON(b []byte) error { 65 | var sta station 66 | if err := json.Unmarshal(b, &sta); err != nil { 67 | return err 68 | } 69 | 70 | apMAC, err := net.ParseMAC(sta.ApMac) 71 | if !sta.IsWired && err != nil { 72 | return err 73 | } 74 | 75 | mac, err := net.ParseMAC(sta.Mac) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | *s = Station{ 81 | ID: sta.ID, 82 | APMAC: apMAC, 83 | AssociationTime: time.Unix(int64(sta.AssocTime), 0), 84 | Channel: sta.Channel, 85 | FirstSeen: time.Unix(int64(sta.FirstSeen), 0), 86 | Hostname: sta.Hostname, 87 | IdleTime: time.Duration(time.Duration(sta.Idletime) * time.Second), 88 | IP: net.ParseIP(sta.IP), 89 | IsWired: sta.IsWired, 90 | LastSeen: time.Unix(int64(sta.LastSeen), 0), 91 | MAC: mac, 92 | Name: sta.Name, 93 | Noise: sta.Noise, 94 | RSSI: sta.RSSI, 95 | RoamCount: sta.RoamCount, 96 | SiteID: sta.SiteID, 97 | Stats: &StationStats{ 98 | ReceiveBytes: sta.RxBytes, 99 | ReceivePackets: sta.RxPackets, 100 | ReceiveRate: sta.RxRate, 101 | TransmitBytes: sta.TxBytes, 102 | TransmitPackets: sta.TxPackets, 103 | TransmitPower: sta.TxPower, 104 | TransmitRate: sta.TxRate, 105 | }, 106 | Uptime: time.Duration(time.Duration(sta.Uptime) * time.Second), 107 | UserID: sta.UserID, 108 | } 109 | 110 | return nil 111 | } 112 | 113 | // A station is the raw structure of a Station returned from the UniFi Controller 114 | // API. 115 | type station struct { 116 | // TODO(mdlayher): give all fields appropriate names and data types. 117 | ID string `json:"_id"` 118 | IsGuestByUap bool `json:"_is_guest_by_uap"` 119 | LastSeenByUap int `json:"_last_seen_by_uap"` 120 | UptimeByUap int `json:"_uptime_by_uap"` 121 | ApMac string `json:"ap_mac"` 122 | AssocTime int `json:"assoc_time"` 123 | Authorized bool `json:"authorized"` 124 | Bssid string `json:"bssid"` 125 | BytesR int64 `json:"bytes-r"` 126 | Ccq int `json:"ccq"` 127 | Channel int `json:"channel"` 128 | Essid string `json:"essid"` 129 | FirstSeen int `json:"first_seen"` 130 | Hostname string `json:"hostname"` 131 | Idletime int `json:"idletime"` 132 | IP string `json:"ip"` 133 | IsGuest bool `json:"is_guest"` 134 | IsWired bool `json:"is_wired"` 135 | LastSeen int `json:"last_seen"` 136 | Mac string `json:"mac"` 137 | Name string `json:"name"` 138 | Noise int `json:"noise"` 139 | Oui string `json:"oui"` 140 | PowersaveEnabled bool `json:"powersave_enabled"` 141 | QosPolicyApplied bool `json:"qos_policy_applied"` 142 | Radio string `json:"radio"` 143 | RadioProto string `json:"radio_proto"` 144 | RoamCount int `json:"roam_count"` 145 | RSSI int `json:"rssi"` 146 | RxBytes int64 `json:"rx_bytes"` 147 | RxBytesR int64 `json:"rx_bytes-r"` 148 | RxPackets int64 `json:"rx_packets"` 149 | RxRate int `json:"rx_rate"` 150 | Signal int `json:"signal"` 151 | SiteID string `json:"site_id"` 152 | TxBytes int64 `json:"tx_bytes"` 153 | TxBytesR int64 `json:"tx_bytes-r"` 154 | TxPackets int64 `json:"tx_packets"` 155 | TxPower int `json:"tx_power"` 156 | TxRate int `json:"tx_rate"` 157 | Uptime int `json:"uptime"` 158 | UserID string `json:"user_id"` 159 | } 160 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Package unifi implements a client for the Ubiquiti UniFi Controller v4 and 2 | // v5 API. 3 | package unifi 4 | 5 | import ( 6 | "bytes" 7 | "crypto/tls" 8 | "encoding/json" 9 | "fmt" 10 | "net/http" 11 | "net/http/cookiejar" 12 | "net/url" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | const ( 18 | // Predefined content types for HTTP requests. 19 | formEncodedContentType = "application/x-www-form-urlencoded" 20 | jsonContentType = "application/json;charset=UTF-8" 21 | 22 | // userAgent is the default user agent this package will report to the UniFi 23 | // Controller v4 API. 24 | userAgent = "github.com/mdlayher/unifi" 25 | ) 26 | 27 | // InsecureHTTPClient creates a *http.Client which does not verify a UniFi 28 | // Controller's certificate chain and hostname. 29 | // 30 | // Please think carefully before using this client: it should only be used 31 | // with self-hosted, internal UniFi Controllers. 32 | func InsecureHTTPClient(timeout time.Duration) *http.Client { 33 | return &http.Client{ 34 | Timeout: timeout, 35 | Transport: &http.Transport{ 36 | TLSClientConfig: &tls.Config{ 37 | InsecureSkipVerify: true, 38 | }, 39 | }, 40 | } 41 | } 42 | 43 | // A Client is a client for the Ubiquiti UniFi Controller v4 API. 44 | // 45 | // Client.Login must be called and return a nil error before any additional 46 | // actions can be performed with a Client. 47 | type Client struct { 48 | UserAgent string 49 | 50 | apiURL *url.URL 51 | client *http.Client 52 | } 53 | 54 | // NewClient creates a new Client, using the input API address and an optional 55 | // HTTP client. If no HTTP client is specified, a default one will be used. 56 | // 57 | // If working with a self-hosted UniFi Controller which does not have a valid 58 | // TLS certificate, InsecureHTTPClient can be used. 59 | // 60 | // Client.Login must be called and return a nil error before any additional 61 | // actions can be performed with a Client. 62 | func NewClient(addr string, client *http.Client) (*Client, error) { 63 | // Trim trailing slash to ensure sane path creation in other methods 64 | u, err := url.Parse(strings.TrimRight(addr, "/")) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if client == nil { 70 | client = &http.Client{ 71 | Timeout: 10 * time.Second, 72 | } 73 | } 74 | 75 | if client.Jar == nil { 76 | jar, err := cookiejar.New(nil) 77 | if err != nil { 78 | return nil, err 79 | } 80 | client.Jar = jar 81 | } 82 | 83 | c := &Client{ 84 | UserAgent: userAgent, 85 | 86 | apiURL: u, 87 | client: client, 88 | } 89 | 90 | return c, nil 91 | } 92 | 93 | // Login authenticates against the UniFi Controller using the specified 94 | // username and password. Login must be called and return a nil error before 95 | // any additional actions can be performed. 96 | func (c *Client) Login(username string, password string) error { 97 | auth := &login{ 98 | Username: username, 99 | Password: password, 100 | } 101 | 102 | req, err := c.newRequest(http.MethodPost, "/api/login", auth) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | _, err = c.do(req, nil) 108 | return err 109 | } 110 | 111 | type login struct { 112 | Username string `json:"username"` 113 | Password string `json:"password"` 114 | } 115 | 116 | // newRequest creates a new HTTP request, using the specified HTTP method and 117 | // API endpoint. Additionally, it accepts a struct which can be marshaled to 118 | // a JSON body. 119 | func (c *Client) newRequest(method string, endpoint string, body interface{}) (*http.Request, error) { 120 | rel, err := url.Parse(endpoint) 121 | if err != nil { 122 | return nil, err 123 | } 124 | u := c.apiURL.ResolveReference(rel) 125 | 126 | hasBody := method == http.MethodPost && body != nil 127 | var length int64 128 | 129 | // If performing a POST request and body parameters exist, encode 130 | // them now 131 | buf := bytes.NewBuffer(nil) 132 | if hasBody { 133 | if err := json.NewEncoder(buf).Encode(body); err != nil { 134 | return nil, err 135 | } 136 | length = int64(buf.Len()) 137 | } 138 | 139 | req, err := http.NewRequest(method, u.String(), buf) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | // For POST requests, add proper headers 145 | if hasBody { 146 | req.Header.Add("Content-Type", formEncodedContentType) 147 | req.ContentLength = length 148 | } 149 | 150 | req.Header.Add("Accept", jsonContentType) 151 | req.Header.Add("User-Agent", c.UserAgent) 152 | 153 | return req, nil 154 | } 155 | 156 | // do performs an HTTP request using req and unmarshals the result onto v, if 157 | // v is not nil. 158 | func (c *Client) do(req *http.Request, v interface{}) (*http.Response, error) { 159 | res, err := c.client.Do(req) 160 | if err != nil { 161 | return nil, err 162 | } 163 | defer res.Body.Close() 164 | 165 | if err := checkResponse(res); err != nil { 166 | return res, err 167 | } 168 | 169 | // If no second parameter was passed, do not attempt to handle response 170 | if v == nil { 171 | return res, nil 172 | } 173 | 174 | return res, json.NewDecoder(res.Body).Decode(v) 175 | } 176 | 177 | // checkResponse checks for correct content type in a response and for non-200 178 | // HTTP status codes, and returns any errors encountered. 179 | func checkResponse(res *http.Response) error { 180 | if cType := res.Header.Get("Content-Type"); cType != jsonContentType { 181 | return fmt.Errorf("expected %q content type, but received %q", jsonContentType, cType) 182 | } 183 | 184 | // Check for 200-range status code 185 | if c := res.StatusCode; 200 <= c && c <= 299 { 186 | return nil 187 | } 188 | 189 | return fmt.Errorf("unexpected HTTP status code: %d", res.StatusCode) 190 | } 191 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestClientBadContentType(t *testing.T) { 16 | c, done := testClient(t, func(w http.ResponseWriter, r *http.Request) { 17 | _, _ = w.Write([]byte(`foo`)) 18 | }) 19 | defer done() 20 | 21 | req, err := c.newRequest(http.MethodGet, "/", nil) 22 | if err != nil { 23 | t.Fatalf("unexpected error: %v", err) 24 | } 25 | 26 | // Not the best possible check but verifies that the content type is incorrect 27 | _, err = c.do(req, nil) 28 | if want, got := `received "text/plain; charset=utf-8"`, err.Error(); !strings.Contains(got, want) { 29 | t.Fatalf("unexpected error message: %v", got) 30 | } 31 | } 32 | 33 | func TestClientBadHTTPStatusCode(t *testing.T) { 34 | c, done := testClient(t, func(w http.ResponseWriter, r *http.Request) { 35 | w.Header().Set("Content-Type", jsonContentType) 36 | w.WriteHeader(http.StatusInternalServerError) 37 | }) 38 | defer done() 39 | 40 | req, err := c.newRequest(http.MethodGet, "/", nil) 41 | if err != nil { 42 | t.Fatalf("unexpected error: %v", err) 43 | } 44 | 45 | // Not the best possible check but verifies that the content type is incorrect 46 | _, err = c.do(req, nil) 47 | if want, got := `unexpected HTTP status code: 500`, err.Error(); !strings.Contains(got, want) { 48 | t.Fatalf("unexpected error message: %v", got) 49 | } 50 | } 51 | 52 | func TestClientBadJSON(t *testing.T) { 53 | c, done := testClient(t, func(w http.ResponseWriter, r *http.Request) { 54 | w.Header().Set("Content-Type", jsonContentType) 55 | _, _ = w.Write([]byte(`foo`)) 56 | }) 57 | defer done() 58 | 59 | req, err := c.newRequest(http.MethodGet, "/", nil) 60 | if err != nil { 61 | t.Fatalf("unexpected error: %v", err) 62 | } 63 | 64 | // Pass empty struct to trigger JSON unmarshaling path 65 | var v struct{} 66 | 67 | _, err = c.do(req, &v) 68 | if _, ok := err.(*json.SyntaxError); !ok { 69 | t.Fatalf("unexpected error type: %T", err) 70 | } 71 | } 72 | 73 | func TestClientRetainsCookies(t *testing.T) { 74 | const cookieName = "foo" 75 | wantCookie := &http.Cookie{ 76 | Name: cookieName, 77 | Value: "bar", 78 | } 79 | 80 | var i int 81 | c, done := testClient(t, func(w http.ResponseWriter, r *http.Request) { 82 | defer func() { i++ }() 83 | 84 | w.Header().Set("Content-Type", jsonContentType) 85 | 86 | switch i { 87 | case 0: 88 | http.SetCookie(w, wantCookie) 89 | case 1: 90 | c, err := r.Cookie(cookieName) 91 | if err != nil { 92 | t.Fatalf("unexpected error: %v", err) 93 | } 94 | 95 | if want, got := wantCookie, c; !reflect.DeepEqual(want, got) { 96 | t.Fatalf("unexpected cookie:\n- want: %v\n- got: %v", 97 | want, got) 98 | } 99 | } 100 | 101 | _, _ = w.Write([]byte(`{}`)) 102 | }) 103 | defer done() 104 | 105 | for i := 0; i < 2; i++ { 106 | req, err := c.newRequest(http.MethodGet, "/", nil) 107 | if err != nil { 108 | t.Fatalf("unexpected error: %v", err) 109 | } 110 | 111 | _, err = c.do(req, nil) 112 | if err != nil { 113 | t.Fatalf("unexpected error: %v", err) 114 | } 115 | } 116 | } 117 | 118 | func TestClientLogin(t *testing.T) { 119 | const ( 120 | wantUsername = "test" 121 | wantPassword = "test" 122 | ) 123 | 124 | wantBody := &login{ 125 | Username: wantUsername, 126 | Password: wantPassword, 127 | } 128 | 129 | c, done := testClient(t, testHandler(t, http.MethodPost, "/api/login", wantBody, nil)) 130 | defer done() 131 | 132 | if err := c.Login(wantUsername, wantPassword); err != nil { 133 | t.Fatalf("unexpected error from Client.Login: %v", err) 134 | } 135 | } 136 | 137 | func TestInsecureHTTPClient(t *testing.T) { 138 | timeout := 5 * time.Second 139 | c := InsecureHTTPClient(timeout) 140 | 141 | if want, got := c.Timeout, timeout; want != got { 142 | t.Fatalf("unexpected client timeout:\n- want: %v\n- got: %v", 143 | want, got) 144 | } 145 | 146 | got := c.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify 147 | if want := true; want != got { 148 | t.Fatalf("unexpected client insecure skip verify value:\n- want: %v\n- got: %v", 149 | want, got) 150 | } 151 | } 152 | 153 | func testClient(t *testing.T, fn func(w http.ResponseWriter, r *http.Request)) (*Client, func()) { 154 | s := httptest.NewServer(http.HandlerFunc(fn)) 155 | 156 | c, err := NewClient(s.URL, nil) 157 | if err != nil { 158 | t.Fatalf("error creating Client: %v", err) 159 | } 160 | 161 | return c, func() { s.Close() } 162 | } 163 | 164 | func testHandler(t *testing.T, method string, path string, body interface{}, out interface{}) http.HandlerFunc { 165 | return func(w http.ResponseWriter, r *http.Request) { 166 | if want, got := method, r.Method; want != got { 167 | t.Fatalf("unexpected HTTP method:\n- want: %v\n- got: %v", want, got) 168 | } 169 | 170 | if want, got := path, r.URL.Path; want != got { 171 | t.Fatalf("unexpected URL path:\n- want: %v\n- got: %v", want, got) 172 | } 173 | 174 | if r.Method != http.MethodPost && r.Method != http.MethodPut { 175 | w.Header().Set("Content-Type", jsonContentType) 176 | if err := json.NewEncoder(w).Encode(out); err != nil { 177 | t.Fatalf("error marshaling JSON response body: %v", err) 178 | } 179 | return 180 | } 181 | 182 | // Content-Length must always be set for POST/PUT 183 | cls := r.Header.Get("Content-Length") 184 | if cls == "" { 185 | t.Fatal("Content-Length header is not present or empty") 186 | } 187 | cl, err := strconv.Atoi(cls) 188 | if err != nil { 189 | t.Fatalf("unexpected error parsing Content-Length: %v", err) 190 | } 191 | 192 | // Content-Length must match body length 193 | b := make([]byte, cl) 194 | if n, err := io.ReadFull(r.Body, b); err != nil { 195 | t.Fatalf("failed to read entire JSON body: read %d bytes, err: %v", n, err) 196 | } 197 | 198 | // Body must be valid JSON 199 | var v struct{} 200 | if err := json.Unmarshal(b, &v); err != nil { 201 | t.Fatalf("error unmarshaling JSON body: %v", err) 202 | } 203 | 204 | // Request body must match input JSON 205 | wantJSON, err := json.Marshal(body) 206 | if err != nil { 207 | t.Fatalf("error marshaling input body to JSON: %v", err) 208 | } 209 | 210 | if want, got := string(wantJSON), strings.TrimSpace(string(b)); want != got { 211 | t.Fatalf("unexpected JSON request body:\n- want: %v\n- got: %v", 212 | want, got) 213 | } 214 | 215 | // Write output JSON if set 216 | w.Header().Set("Content-Type", jsonContentType) 217 | if err := json.NewEncoder(w).Encode(out); err != nil { 218 | t.Fatalf("error marshaling JSON response body: %v", err) 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /devices_test.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "reflect" 11 | "strings" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestClientDevices(t *testing.T) { 17 | const ( 18 | wantSite = "default" 19 | wantID = "abcdef123457890" 20 | wantAdopted = true 21 | ) 22 | var ( 23 | wantInformIP = net.IPv4(192, 168, 1, 1) 24 | ) 25 | 26 | wantDevice := &Device{ 27 | ID: wantID, 28 | Adopted: wantAdopted, 29 | InformIP: wantInformIP, 30 | NICs: []*NIC{}, 31 | Radios: []*Radio{}, 32 | Stats: &DeviceStats{ 33 | All: &WirelessStats{}, 34 | User: &WirelessStats{}, 35 | Uplink: &WiredStats{}, 36 | Guest: &WirelessStats{}, 37 | }, 38 | } 39 | 40 | v := struct { 41 | Devices []device `json:"data"` 42 | }{ 43 | Devices: []device{{ 44 | ID: wantID, 45 | Adopted: wantAdopted, 46 | InformIP: wantInformIP.String(), 47 | }}, 48 | } 49 | 50 | c, done := testClient(t, testHandler( 51 | t, 52 | http.MethodGet, 53 | fmt.Sprintf("/api/s/%s/stat/device", wantSite), 54 | nil, 55 | v, 56 | )) 57 | defer done() 58 | 59 | devices, err := c.Devices(wantSite) 60 | if err != nil { 61 | t.Fatalf("unexpected error from Client.Devices: %v", err) 62 | } 63 | 64 | if want, got := 1, len(devices); want != got { 65 | t.Fatalf("unexpected number of Devices:\n- want: %d\n- got: %d", 66 | want, got) 67 | } 68 | 69 | // For easy comparison 70 | wantDevice.InformURL = nil 71 | devices[0].InformURL = nil 72 | 73 | if want, got := wantDevice, devices[0]; !reflect.DeepEqual(want, got) { 74 | t.Fatalf("unexpected Device:\n- want: %#v\n- got: %#v", 75 | want, got) 76 | } 77 | } 78 | 79 | func errStr(err error) string { 80 | if err == nil { 81 | return "" 82 | } 83 | 84 | return err.Error() 85 | } 86 | 87 | func TestDeviceUnmarshalJSON(t *testing.T) { 88 | var tests = []struct { 89 | desc string 90 | b []byte 91 | d *Device 92 | err error 93 | }{ 94 | { 95 | desc: "invalid JSON", 96 | b: []byte(`<>`), 97 | err: errors.New("invalid character"), 98 | }, 99 | { 100 | desc: "invalid inform IP", 101 | b: []byte(`{"inform_ip":"foo"}`), 102 | err: errors.New("failed to parse inform IP"), 103 | }, 104 | { 105 | desc: "invalid NIC MAC", 106 | b: []byte(`{"inform_ip":"192.168.1.1","ethernet_table":[{"mac":"foo"}]}`), 107 | err: errors.New("invalid MAC address"), 108 | }, 109 | { 110 | desc: "OK", 111 | b: bytes.TrimSpace([]byte(` 112 | { 113 | "_id": "abcdef1234567890", 114 | "adopted": true, 115 | "inform_ip": "192.168.1.1", 116 | "inform_url": "http://192.168.1.1:8080/inform", 117 | "model": "uap1000", 118 | "name": "AP", 119 | "ethernet_table": [ 120 | { 121 | "mac": "de:ad:be:ef:de:ad", 122 | "name": "eth0" 123 | } 124 | ], 125 | "radio_table": [ 126 | { 127 | "builtin_ant_gain": 1, 128 | "builtin_antenna": true, 129 | "max_txpower": 10, 130 | "min_txpower": 1, 131 | "name": "wlan0", 132 | "radio": "ng" 133 | }, 134 | { 135 | "builtin_ant_gain": 1, 136 | "builtin_antenna": true, 137 | "max_txpower": 10, 138 | "min_txpower": 1, 139 | "name": "wlan1", 140 | "radio": "na" 141 | } 142 | ], 143 | "radio_table_stats": [{ 144 | "guest-num_sta": 1, 145 | "name": "wlan0", 146 | "num_sta": 3, 147 | "user-num_sta": 2 148 | }, { 149 | "guest-num_sta": 1, 150 | "name": "wlan1", 151 | "num_sta": 3, 152 | "user-num_sta": 2 153 | }], 154 | "serial": "deadbeef0123456789", 155 | "site_id": "default", 156 | "stat": { 157 | "guest-rx_bytes": 101, 158 | "guest-rx_packets": 5, 159 | "guest-tx_bytes": 40, 160 | "guest-tx_dropped": 7, 161 | "guest-tx_packets": 9, 162 | "user-rx_bytes": 80, 163 | "user-rx_packets": 4, 164 | "user-tx_bytes": 20, 165 | "user-tx_dropped": 1, 166 | "user-tx_packets": 1, 167 | "bytes": 100, 168 | "rx_bytes": 80, 169 | "rx_packets": 4, 170 | "tx_bytes": 20, 171 | "tx_dropped": 1, 172 | "tx_packets": 1 173 | }, 174 | "uplink": { 175 | "full_duplex": true, 176 | "ip": "0.0.0.0", 177 | "mac": "de:ad:be:ef:00:00", 178 | "max_speed": 1000, 179 | "name": "eth0", 180 | "netmask": "0.0.0.0", 181 | "num_port": 2, 182 | "rx_bytes": 81, 183 | "rx_dropped": 11023, 184 | "rx_errors": 0, 185 | "rx_multicast": 0, 186 | "rx_packets": 5, 187 | "speed": 1000, 188 | "tx_bytes": 21, 189 | "tx_dropped": 0, 190 | "tx_errors": 0, 191 | "tx_packets": 2, 192 | "type": "wire", 193 | "up": true 194 | }, 195 | "uptime": 61, 196 | "version": "1.0.0" 197 | } 198 | `)), 199 | d: &Device{ 200 | ID: "abcdef1234567890", 201 | Adopted: true, 202 | InformIP: net.IPv4(192, 168, 1, 1), 203 | InformURL: func() *url.URL { 204 | u, err := url.Parse("http://192.168.1.1:8080/inform") 205 | if err != nil { 206 | t.Fatal("failed to parse inform URL") 207 | } 208 | 209 | return u 210 | }(), 211 | Model: "uap1000", 212 | Name: "AP", 213 | NICs: []*NIC{{ 214 | MAC: net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad}, 215 | Name: "eth0", 216 | }}, 217 | Radios: []*Radio{ 218 | { 219 | BuiltInAntenna: true, 220 | BuiltInAntennaGain: 1, 221 | MaxTXPower: 10, 222 | MinTXPower: 1, 223 | Name: "wlan0", 224 | Radio: radio24GHz, 225 | Stats: &RadioStationsStats{ 226 | NumberStations: 3, 227 | NumberUserStations: 2, 228 | NumberGuestStations: 1, 229 | }, 230 | }, 231 | { 232 | BuiltInAntenna: true, 233 | BuiltInAntennaGain: 1, 234 | MaxTXPower: 10, 235 | MinTXPower: 1, 236 | Name: "wlan1", 237 | Radio: radio5GHz, 238 | Stats: &RadioStationsStats{ 239 | NumberStations: 3, 240 | NumberUserStations: 2, 241 | NumberGuestStations: 1, 242 | }, 243 | }, 244 | }, 245 | Serial: "deadbeef0123456789", 246 | SiteID: "default", 247 | Stats: &DeviceStats{ 248 | TotalBytes: 100, 249 | All: &WirelessStats{ 250 | ReceiveBytes: 80, 251 | ReceivePackets: 4, 252 | TransmitBytes: 20, 253 | TransmitDropped: 1, 254 | TransmitPackets: 1, 255 | }, 256 | User: &WirelessStats{ 257 | ReceiveBytes: 80, 258 | ReceivePackets: 4, 259 | TransmitBytes: 20, 260 | TransmitDropped: 1, 261 | TransmitPackets: 1, 262 | }, 263 | Uplink: &WiredStats{ 264 | ReceiveBytes: 81, 265 | ReceivePackets: 5, 266 | TransmitBytes: 21, 267 | TransmitPackets: 2, 268 | }, 269 | Guest: &WirelessStats{ 270 | ReceiveBytes: 101, 271 | ReceivePackets: 5, 272 | TransmitBytes: 40, 273 | TransmitDropped: 7, 274 | TransmitPackets: 9, 275 | }, 276 | }, 277 | Uptime: 61 * time.Second, 278 | Version: "1.0.0", 279 | }, 280 | }, 281 | } 282 | 283 | for _, tt := range tests { 284 | t.Run(tt.desc, func(t *testing.T) { 285 | d := new(Device) 286 | err := d.UnmarshalJSON(tt.b) 287 | if want, got := errStr(tt.err), errStr(err); !strings.Contains(got, want) { 288 | t.Fatalf("unexpected error:\n- want: %v\n- got: %v", 289 | want, got) 290 | } 291 | if tt.err != nil { 292 | return 293 | } 294 | if err != nil { 295 | t.Fatalf("Error parsing json: %v", err) 296 | } 297 | 298 | if want, got := tt.d, d; !reflect.DeepEqual(got, want) { 299 | t.Fatalf("unexpected Device:\n- want: %+v\n- got: %+v", 300 | want, got) 301 | } 302 | }) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /devices.go: -------------------------------------------------------------------------------- 1 | package unifi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | // Devices returns all of the Devices for a specified site name. 12 | func (c *Client) Devices(siteName string) ([]*Device, error) { 13 | var v struct { 14 | Devices []*Device `json:"data"` 15 | } 16 | 17 | req, err := c.newRequest( 18 | "GET", 19 | fmt.Sprintf("/api/s/%s/stat/device", siteName), 20 | nil, 21 | ) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | _, err = c.do(req, &v) 27 | return v.Devices, err 28 | } 29 | 30 | // A Device is a Ubiquiti UniFi device, such as a UniFi access point. 31 | type Device struct { 32 | ID string 33 | Adopted bool 34 | InformIP net.IP 35 | InformURL *url.URL 36 | Model string 37 | Name string 38 | NICs []*NIC 39 | Radios []*Radio 40 | Serial string 41 | SiteID string 42 | Stats *DeviceStats 43 | Uptime time.Duration 44 | Version string 45 | 46 | // TODO(mdlayher): add more fields from unexported device type 47 | } 48 | 49 | // A Radio is a wireless radio, attached to a Device. 50 | type Radio struct { 51 | BuiltInAntenna bool 52 | BuiltInAntennaGain int 53 | MaxTXPower int 54 | MinTXPower int 55 | Name string 56 | Radio string 57 | Stats *RadioStationsStats 58 | } 59 | 60 | // RadioStationsStats contains Station statistics for a Radio. 61 | type RadioStationsStats struct { 62 | NumberStations int 63 | NumberGuestStations int 64 | NumberUserStations int 65 | } 66 | 67 | // A NIC is a wired ethernet network interface, attached to a Device. 68 | type NIC struct { 69 | MAC net.HardwareAddr 70 | Name string 71 | } 72 | 73 | // DeviceStats contains device network activity statistics. 74 | type DeviceStats struct { 75 | TotalBytes float64 76 | All *WirelessStats 77 | Guest *WirelessStats 78 | User *WirelessStats 79 | Uplink *WiredStats 80 | } 81 | 82 | func (s *DeviceStats) String() string { 83 | return fmt.Sprintf("%v", *s) 84 | } 85 | 86 | // WirelessStats contains wireless device network activity statistics. 87 | type WirelessStats struct { 88 | ReceiveBytes float64 89 | ReceivePackets float64 90 | TransmitBytes float64 91 | TransmitDropped float64 92 | TransmitPackets float64 93 | } 94 | 95 | func (s *WirelessStats) String() string { 96 | return fmt.Sprintf("%v", *s) 97 | } 98 | 99 | // WiredStats contains wired device network activity statistics. 100 | type WiredStats struct { 101 | ReceiveBytes float64 102 | ReceivePackets float64 103 | TransmitBytes float64 104 | TransmitPackets float64 105 | } 106 | 107 | func (s *WiredStats) String() string { 108 | return fmt.Sprintf("%v", *s) 109 | } 110 | 111 | const ( 112 | radioNA = "na" 113 | radioNG = "ng" 114 | 115 | radio5GHz = "5GHz" 116 | radio24GHz = "2.4GHz" 117 | ) 118 | 119 | // UnmarshalJSON unmarshals the raw JSON representation of a Device. 120 | func (d *Device) UnmarshalJSON(b []byte) error { 121 | var dev device 122 | if err := json.Unmarshal(b, &dev); err != nil { 123 | return err 124 | } 125 | 126 | informIP := net.ParseIP(dev.InformIP) 127 | if informIP == nil { 128 | return fmt.Errorf("failed to parse inform IP: %v", dev.InformIP) 129 | } 130 | 131 | informURL, err := url.Parse(dev.InformURL) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | nics := make([]*NIC, 0, len(dev.EthernetTable)) 137 | for _, et := range dev.EthernetTable { 138 | mac, err := net.ParseMAC(et.MAC) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | nics = append(nics, &NIC{ 144 | MAC: mac, 145 | Name: et.Name, 146 | }) 147 | } 148 | 149 | radios := make([]*Radio, 0, len(dev.RadioTable)) 150 | for _, rt := range dev.RadioTable { 151 | r := &Radio{ 152 | BuiltInAntenna: rt.BuiltinAntenna, 153 | BuiltInAntennaGain: rt.BuiltinAntGain, 154 | MaxTXPower: rt.MaxTXPower, 155 | MinTXPower: rt.MinTXPower, 156 | Name: rt.Name, 157 | } 158 | 159 | for _, v := range dev.RadioTableStats { 160 | if v.Name == rt.Name { 161 | r.Stats = &RadioStationsStats{ 162 | NumberStations: v.NumSta, 163 | NumberUserStations: v.UserNumSta, 164 | NumberGuestStations: v.GuestNumSta, 165 | } 166 | } 167 | } 168 | 169 | switch rt.Radio { 170 | case radioNA: 171 | r.Radio = radio5GHz 172 | case radioNG: 173 | r.Radio = radio24GHz 174 | } 175 | 176 | radios = append(radios, r) 177 | } 178 | 179 | *d = Device{ 180 | ID: dev.ID, 181 | Adopted: dev.Adopted, 182 | InformIP: informIP, 183 | InformURL: informURL, 184 | Model: dev.Model, 185 | Name: dev.Name, 186 | NICs: nics, 187 | Radios: radios, 188 | Serial: dev.Serial, 189 | SiteID: dev.SiteID, 190 | Uptime: time.Duration(time.Duration(dev.Uptime) * time.Second), 191 | Version: dev.Version, 192 | Stats: &DeviceStats{ 193 | TotalBytes: dev.Stat.Bytes, 194 | All: &WirelessStats{ 195 | ReceiveBytes: dev.Stat.RxBytes, 196 | ReceivePackets: dev.Stat.RxPackets, 197 | TransmitBytes: dev.Stat.TxBytes, 198 | TransmitDropped: dev.Stat.TxDropped, 199 | TransmitPackets: dev.Stat.TxPackets, 200 | }, 201 | User: &WirelessStats{ 202 | ReceiveBytes: dev.Stat.UserRxBytes, 203 | ReceivePackets: dev.Stat.UserRxPackets, 204 | TransmitBytes: dev.Stat.UserTxBytes, 205 | TransmitDropped: dev.Stat.UserTxDropped, 206 | TransmitPackets: dev.Stat.UserTxPackets, 207 | }, 208 | Guest: &WirelessStats{ 209 | ReceiveBytes: dev.Stat.GuestRxBytes, 210 | ReceivePackets: dev.Stat.GuestRxPackets, 211 | TransmitBytes: dev.Stat.GuestTxBytes, 212 | TransmitDropped: dev.Stat.GuestTxDropped, 213 | TransmitPackets: dev.Stat.GuestTxPackets, 214 | }, 215 | Uplink: &WiredStats{ 216 | ReceiveBytes: dev.Uplink.RxBytes, 217 | ReceivePackets: dev.Uplink.RxPackets, 218 | TransmitBytes: dev.Uplink.TxBytes, 219 | TransmitPackets: dev.Uplink.TxPackets, 220 | }, 221 | }, 222 | } 223 | 224 | return nil 225 | } 226 | 227 | // A device is the raw structure of a Device returned from the UniFi Controller 228 | // API. 229 | type device struct { 230 | // TODO(mdlayher): give all fields appropriate names and data types. 231 | ID string `json:"_id"` 232 | Adopted bool `json:"adopted"` 233 | Bytes float64 `json:"bytes"` 234 | ConfigVersion string `json:"cfgversion"` 235 | ConfigNetwork struct { 236 | IP string `json:"ip"` 237 | Type string `json:"type"` 238 | } `json:"config_network"` 239 | DeviceID string `json:"device_id"` 240 | EthernetTable []struct { 241 | MAC string `json:"mac"` 242 | Name string `json:"name"` 243 | NumPort int `json:"num_port"` 244 | } `json:"ethernet_table"` 245 | GuestNumSta int `json:"guest-num_sta"` 246 | HasSpeaker bool `json:"has_speaker"` 247 | InformIP string `json:"inform_ip"` 248 | InformURL string `json:"inform_url"` 249 | IP string `json:"ip"` 250 | LastSeen int `json:"last_seen"` 251 | MAC string `json:"mac"` 252 | Model string `json:"model"` 253 | Name string `json:"name"` 254 | NumSta int `json:"num_sta"` 255 | RadioNg struct { 256 | BuiltInAntennaGain int `json:"builtin_ant_gain"` 257 | BuiltInAntenna bool `json:"builtin_antenna"` 258 | MaxTXPower int `json:"max_txpower"` 259 | MinTXPower int `json:"min_txpower"` 260 | Name string `json:"name"` 261 | Radio string `json:"radio"` 262 | } `json:"radio_ng"` 263 | RadioTable []struct { 264 | BuiltinAntGain int `json:"builtin_ant_gain"` 265 | BuiltinAntenna bool `json:"builtin_antenna"` 266 | MaxTXPower int `json:"max_txpower"` 267 | MinTXPower int `json:"min_txpower"` 268 | Name string `json:"name"` 269 | Radio string `json:"radio"` 270 | } `json:"radio_table"` 271 | RadioTableStats []struct { 272 | AstBeXmit int `json:"ast_be_xmit"` 273 | AstCst int `json:"ast_cst"` 274 | AstTxto interface{} `json:"ast_txto"` 275 | Channel int `json:"channel"` 276 | CuSelfRx int `json:"cu_self_rx"` 277 | CuSelfTx int `json:"cu_self_tx"` 278 | CuTotal int `json:"cu_total"` 279 | Extchannel int `json:"extchannel"` 280 | Gain int `json:"gain"` 281 | GuestNumSta int `json:"guest-num_sta"` 282 | Name string `json:"name"` 283 | NumSta int `json:"num_sta"` 284 | Radio string `json:"radio"` 285 | State string `json:"state"` 286 | TxPackets int `json:"tx_packets"` 287 | TxPower int `json:"tx_power"` 288 | TxRetries int `json:"tx_retries"` 289 | UserNumSta int `json:"user-num_sta"` 290 | } `json:"radio_table_stats"` 291 | RxBytes float64 `json:"rx_bytes"` 292 | Serial string `json:"serial,omitempty"` 293 | SiteID string `json:"site_id"` 294 | Stat struct { 295 | Bytes float64 `json:"bytes"` 296 | GuestRxBytes float64 `json:"guest-rx_bytes"` 297 | GuestRxPackets float64 `json:"guest-rx_packets"` 298 | GuestTxBytes float64 `json:"guest-tx_bytes"` 299 | GuestTxDropped float64 `json:"guest-tx_dropped"` 300 | GuestTxPackets float64 `json:"guest-tx_packets"` 301 | Mac string `json:"mac"` 302 | RxBytes float64 `json:"rx_bytes"` 303 | RxPackets float64 `json:"rx_packets"` 304 | TxBytes float64 `json:"tx_bytes"` 305 | TxDropped float64 `json:"tx_dropped"` 306 | TxPackets float64 `json:"tx_packets"` 307 | UserRxBytes float64 `json:"user-rx_bytes"` 308 | UserRxPackets float64 `json:"user-rx_packets"` 309 | UserTxBytes float64 `json:"user-tx_bytes"` 310 | UserTxDropped float64 `json:"user-tx_dropped"` 311 | UserTxPackets float64 `json:"user-tx_packets"` 312 | } `json:"stat"` 313 | Uplink struct { 314 | RxBytes float64 `json:"rx_bytes"` 315 | RxPackets float64 `json:"rx_packets"` 316 | RxErrors float64 `json:"rx_errors"` 317 | TxBytes float64 `json:"tx_bytes"` 318 | TxPackets float64 `json:"tx_packets"` 319 | TxErrors float64 `json:"tx_errors"` 320 | Type string `json:"type"` 321 | } `json:"uplink"` 322 | State int `json:"state"` 323 | TxBytes float64 `json:"tx_bytes"` 324 | Type string `json:"type"` 325 | UplinkTable []interface{} `json:"uplink_table"` 326 | Uptime int `json:"uptime"` 327 | UserNumSta int `json:"user-num_sta"` 328 | Version string `json:"version"` 329 | VwireEnabled bool `json:"vwireEnabled"` 330 | VwireTable []interface{} `json:"vwire_table"` 331 | WlangroupIDNg string `json:"wlangroup_id_ng"` 332 | XAuthkey string `json:"x_authkey"` 333 | XFingerprint string `json:"x_fingerprint"` 334 | XVwirekey string `json:"x_vwirekey"` 335 | } 336 | --------------------------------------------------------------------------------