commit 751ddc2436da73a042fa94c7dd7552ff6cc4e0ed Author: Tordarus Date: Sat Feb 1 17:38:29 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b97602b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*_test.go \ No newline at end of file diff --git a/ap_info.go b/ap_info.go new file mode 100644 index 0000000..082eb40 --- /dev/null +++ b/ap_info.go @@ -0,0 +1,33 @@ +package omadaapi + +import ( + "fmt" + + "git.tordarus.net/tordarus/ezhttp" + "git.tordarus.net/tordarus/omada-api/model" +) + +func (api *Api) GetApInfo(siteID model.SiteID, macAddress string) (*model.ApInfo, error) { + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("GET"), + ezhttp.AppendPath( + fmt.Sprintf("/openapi/v1/%s/sites/%s/aps/%s", + api.config.OmadaID, + siteID, + macAddress)), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.Response[model.ApInfo]](resp.Body) + if err != nil { + return nil, err + } + + return &response.Result, nil +} diff --git a/api.go b/api.go new file mode 100644 index 0000000..8d02944 --- /dev/null +++ b/api.go @@ -0,0 +1,151 @@ +package omadaapi + +import ( + "fmt" + "net/http" + + "git.tordarus.net/tordarus/ezhttp" + "git.tordarus.net/tordarus/omada-api/model" +) + +type Api struct { + tmpl *http.Request + + config ApiConfig + + accessToken string + refreshToken string +} + +type ApiConfig struct { + BasePath string + OmadaID string + ClientID string + ClientSecret string + Username string + Password string +} + +func NewApi(config ApiConfig) (*Api, error) { + tmpl := ezhttp.Request(ezhttp.URL(config.BasePath)) + + api := &Api{ + tmpl: tmpl, + config: config, + } + + loginResponse, err := api.Login() + if err != nil { + return nil, fmt.Errorf("login request failed: %w", err) + } + + if loginResponse.ErrorCode != 0 { + return nil, fmt.Errorf("login request failed: %s", loginResponse.Message) + } + + authCodeResponse, err := api.AuthCode(loginResponse.Result.CsrfToken, loginResponse.Result.SessionID) + if err != nil { + return nil, fmt.Errorf("auth code request failed: %w", err) + } + + if authCodeResponse.ErrorCode != 0 { + return nil, fmt.Errorf("auth code request failed: %s", authCodeResponse.Message) + } + + authTokenResponse, err := api.AuthToken(*authCodeResponse.Result) + if err != nil { + return nil, fmt.Errorf("auth token request failed: %w", err) + } + + if authTokenResponse.ErrorCode != 0 { + return nil, fmt.Errorf("auth token request failed: %s", authTokenResponse.Message) + } + + api.accessToken = authTokenResponse.Result.AccessToken + api.refreshToken = authTokenResponse.Result.RefreshToken + + api.tmpl = ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Headers("Authorization", "AccessToken="+api.accessToken), + ) + + return api, nil +} + +func (api *Api) Login() (*model.LoginResponse, error) { + reqBody := model.LoginRequest{ + Username: api.config.Username, + Password: api.config.Password, + } + + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("POST"), + ezhttp.AppendPath("/openapi/authorize/login"), + ezhttp.Query("client_id", api.config.ClientID, "omadac_id", api.config.OmadaID), + ezhttp.Body(ezhttp.JSON(reqBody)), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.LoginResponse](resp.Body) + if err != nil { + return nil, err + } + + return response, nil +} + +func (api *Api) AuthCode(csrfToken, sessionID string) (*model.AuthCodeResponse, error) { + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("POST"), + ezhttp.AppendPath("/openapi/authorize/code"), + ezhttp.Query("client_id", api.config.ClientID, "omadac_id", api.config.OmadaID, "response_type", "code"), + ezhttp.Headers("Csrf-Token", csrfToken, "Cookie", fmt.Sprintf("TPOMADA_SESSIONID=%s", sessionID)), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.AuthCodeResponse](resp.Body) + if err != nil { + return nil, err + } + + return response, nil +} + +func (api *Api) AuthToken(authCode string) (*model.AuthTokenResponse, error) { + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("POST"), + ezhttp.AppendPath("/openapi/authorize/token"), + ezhttp.Query( + "code", authCode, + "grant_type", "authorization_code", + "client_id", api.config.ClientID, + "client_secret", api.config.ClientSecret, + ), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.AuthTokenResponse](resp.Body) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..3b8fdcc --- /dev/null +++ b/client.go @@ -0,0 +1,59 @@ +package omadaapi + +import ( + "fmt" + "strconv" + + "git.tordarus.net/tordarus/ezhttp" + "git.tordarus.net/tordarus/omada-api/model" +) + +func (api *Api) GetClients(siteID model.SiteID) <-chan *model.Client { + out := make(chan *model.Client, 1000) + + go func() { + defer close(out) + + for page := 1; ; page++ { + resp, err := api.getClients(page, siteID) + if err != nil { + return + } + + for _, v := range resp.Result.Data { + out <- &v + } + + if resp.Result.CurrentPage*resp.Result.CurrentSize >= resp.Result.TotalRows { + break + } + } + }() + + return out +} + +func (api *Api) getClients(page int, siteID model.SiteID) (*model.PagedResponse[model.Client], error) { + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("GET"), + ezhttp.AppendPath(fmt.Sprintf("/openapi/v1/%s/sites/%s/clients", api.config.OmadaID, siteID)), + ezhttp.Query( + "page", strconv.Itoa(page), + "pageSize", "100", + ), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.PagedResponse[model.Client]](resp.Body) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/device.go b/device.go new file mode 100644 index 0000000..22bcf0e --- /dev/null +++ b/device.go @@ -0,0 +1,59 @@ +package omadaapi + +import ( + "fmt" + "strconv" + + "git.tordarus.net/tordarus/ezhttp" + "git.tordarus.net/tordarus/omada-api/model" +) + +func (api *Api) GetDevices(siteID model.SiteID) <-chan *model.Device { + out := make(chan *model.Device, 1000) + + go func() { + defer close(out) + + for page := 1; ; page++ { + resp, err := api.getDevices(page, siteID) + if err != nil { + return + } + + for _, v := range resp.Result.Data { + out <- &v + } + + if resp.Result.CurrentPage*resp.Result.CurrentSize >= resp.Result.TotalRows { + break + } + } + }() + + return out +} + +func (api *Api) getDevices(page int, siteID model.SiteID) (*model.PagedResponse[model.Device], error) { + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("GET"), + ezhttp.AppendPath(fmt.Sprintf("/openapi/v1/%s/sites/%s/devices", api.config.OmadaID, siteID)), + ezhttp.Query( + "page", strconv.Itoa(page), + "pageSize", "100", + ), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.PagedResponse[model.Device]](resp.Body) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..82b4238 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.tordarus.net/tordarus/omada-api + +go 1.23.0 + +require git.tordarus.net/tordarus/ezhttp v0.0.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d64335d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.tordarus.net/tordarus/ezhttp v0.0.3 h1:K6IlLmqkAFUF68HJsOTKcP3ejco7qfm+MuEagohoouo= +git.tordarus.net/tordarus/ezhttp v0.0.3/go.mod h1:Zq9o0Hibny61GqSCwJHa0PfGjVoUFv/zt2PjiQHXvmY= diff --git a/model/ap_info.go b/model/ap_info.go new file mode 100644 index 0000000..e0231c7 --- /dev/null +++ b/model/ap_info.go @@ -0,0 +1,40 @@ +package model + +import "encoding/json" + +type ApInfo struct { + Type string `json:"type"` + MacAddress string `json:"mac"` + Name string `json:"name"` + IP string `json:"ip"` + IPv6 []string `json:"ipv6List"` + WlanGroupID string `json:"wlanId"` + WirelessUplinkInfo *ApWirelessUplinkInfo `json:"wirelessUplink"` + Model string `json:"model"` + FirmwareVersion string `json:"firmwareVersion"` + CpuUtil int `json:"cpuUtil"` + MemUtil int `json:"memoryUtil"` + UptimeSeconds int `json:"uptimeLong"` +} + +func (i ApInfo) String() string { + data, _ := json.MarshalIndent(i, "", "\t") + return string(data) +} + +type ApWirelessUplinkInfo struct { + UplinkMacAddress string `json:"uplinkMac"` + Name string `json:"name"` + Channel int `json:"channel"` + SignalStrengh int `json:"rssi"` + SignalNoiseRatio int `json:"snr"` + TransferRate string `json:"txRate"` + TransferRateMbps int `json:"txRateInt"` + ReceiveRate string `json:"rxRate"` + ReceiveRateMbps int `json:"rxRateInt"` + UpBytes int `json:"upBytes"` + DownBytes int `json:"downBytes"` + UpPackets int `json:"upPackets"` + DownPackets int `json:"downPackets"` + Activity int `json:"activity"` +} diff --git a/model/client.go b/model/client.go new file mode 100644 index 0000000..53707e3 --- /dev/null +++ b/model/client.go @@ -0,0 +1,209 @@ +package model + +import "encoding/json" + +type Client struct { + ID string `json:"id"` + MacAddress string `json:"mac"` + Name string `json:"name"` + Hostname string `json:"hostName"` + Vendor string `json:"vendor"` + DeviceType string `json:"deviceType"` + DeviceCategory string `json:"deviceCategory"` + OsName string `json:"osName"` + IP string `json:"ip"` + IPv6List []string `json:"ipv6List"` + ConnectionType ConnectionType `json:"connectType"` + ConnectionDevType ConnectionDeviceType `json:"connectDevType"` + ConnectedToWirelessRouter bool `json:"connectedToWirelessRouter"` + Wireless bool `json:"wireless"` + SSID string `json:"ssid"` + SignalLevel int `json:"signalLevel"` + HealthScore int `json:"healthScore"` + SignalRank int `json:"signalRank"` + WifiMode WifiMode `json:"wifiMode"` + ApName string `json:"apName"` + ApMac string `json:"apMac"` + RadioID RadioID `json:"radioId"` + Channel int `json:"channel"` + ReceiveRateKbits int `json:"rxRate"` + TransferRateKbits int `json:"txRate"` + PowerSave bool `json:"powerSave"` + SignalStrengh int `json:"rssi"` + SignalNoiseRatio int `json:"snr"` + SwitchMac string `json:"switchMac"` + SwitchName string `json:"switchName"` + GatewayMac string `json:"gatewayMac"` + GatewayName string `json:"gatewayName"` + VLanID int `json:"vid"` + NetworkName string `json:"networkName"` + Dot1xIdentity string `json:"dot1xIdentity"` + Dot1xVlan int `json:"dot1xVlan"` + Port int `json:"port"` + LagID int `json:"lagId"` + Activity int `json:"activity"` + TrafficDownBytes int `json:"trafficDown"` + TrafficUpBytes int `json:"trafficUp"` + UptimeSeconds int `json:"uptime"` + LastSeen int `json:"lastSeen"` + AuthStatus AuthStatus `json:"authStatus"` + Blocked bool `json:"blocked"` + Guest bool `json:"guest"` + Active bool `json:"active"` + Manager bool `json:"manager"` + IpSetting *ClientIpSetting `json:"ipSetting"` + DownPacket int `json:"downPacket"` + UpPacket int `json:"upPacket"` + RateLimit *ClientRateLimit `json:"rateLimit"` + ClientLockToApSetting *ClientLockToApSetting `json:"clientLockToApSetting"` + MultiLink []*ClientMultiFrequencyInfo `json:"multiLink"` + Unit int `json:"unit"` + StandardPort string `json:"standardPort"` + BlockDisable bool `json:"blockDisable"` + DhcpLeaseTimeSeconds int `json:"dhcpLeaseTime"` + SystemName string `json:"systemName"` + Description string `json:"description"` + Capabilities []string `json:"capabilities"` + ClientStat *ClientStat `json:"clientStat"` +} + +func (c Client) String() string { + data, _ := json.MarshalIndent(c, "", "\t") + return string(data) +} + +type ConnectionType int + +const ( + ConnectionTypeWirelessGuest ConnectionType = iota + ConnectionTypeWirelessUser + ConnectionTypeWiredUser +) + +type ConnectionDeviceType string + +const ( + ConnectionDeviceTypeAP ConnectionDeviceType = "ap" + ConnectionDeviceTypeSwitch ConnectionDeviceType = "switch" + ConnectionDeviceTypeGateway ConnectionDeviceType = "gateway" +) + +type WifiMode int + +const ( + WifiMode11a WifiMode = iota + WifiMode11b + WifiMode11g + WifiMode11na + WifiMode11ng + WifiMode11ac + WifiMode11axa + WifiMode11axg + WifiMode11beg + WifiMode11bea +) + +type RadioID int + +const ( + RadioID_2_4G RadioID = iota + RadioID_5G RadioID = iota + RadioID_5G2 RadioID = iota + RadioID_6G RadioID = iota +) + +type AuthStatus int + +const ( + AuthStatusConnected AuthStatus = iota + AuthStatusPending + AuthStatusAuthorized + AuthStatusAuthFree +) + +type ClientIpSetting struct { + UseFixedAddr bool `json:"useFixedAddr"` + NetID string `json:"netId"` + IP string `json:"ip"` +} + +type ClientRateLimit struct { + Mode RateLimitMode `json:"mode"` + RateLimitProfileID string `json:"rateLimitProfileId"` + CustomRateLimit *CustomRateLimit `json:"customRateLimit"` +} + +type RateLimitMode int + +const ( + RateLimitModeCustom RateLimitMode = iota + RateLimitModeProfile +) + +type CustomRateLimit struct { + UpEnable bool `json:"upEnable"` + UpUnit SpeedUnit `json:"upUnit"` + UpLimit int `json:"upLimit"` + DownEnable bool `json:"downEnable"` + DownUnit SpeedUnit `json:"downUnit"` + DownLimit int `json:"downLimit"` +} + +type SpeedUnit int + +const ( + SpeedUnitKbps SpeedUnit = iota + SpeedUnitMbps +) + +type ClientLockToApSetting struct { + Enable bool `json:"enable"` + APs []*ApBriefInfo `json:"aps"` +} + +type ApBriefInfo struct { + Name string `json:"name"` + MacAddress string `json:"mac"` +} + +type ClientMultiFrequencyInfo struct { + RadioID int `json:"radioId"` + WifiMode WifiMode `json:"wifiMode"` + Channel int `json:"channel"` + ReceiveRateKbits int `json:"rxRate"` + TransferRateKbits int `json:"txRate"` + PowerSave bool `json:"powerSave"` + SignalStrengh int `json:"rssi"` + SignalNoiseRatio int `json:"snr"` + SignalLevel int `json:"signalLevel"` + SignalRank int `json:"signalRank"` + UpPacket int `json:"upPacket"` + DownPacket int `json:"downPacket"` + TrafficDownBytes int `json:"trafficDown"` + TrafficUpBytes int `json:"trafficUp"` + Activity int `json:"activity"` + SignalLevelAndRank int `json:"signalLevelAndRank"` +} + +type ClientStat struct { + Total int `json:"total"` + Wireless int `json:"wireless"` + Wired int `json:"wired"` + Num2G int `json:"num2g"` + Num5G int `json:"num5g"` + Num6G int `json:"num6g"` + NumUser int `json:"numUser"` + NumGuest int `json:"numGuest"` + NumWirelessUser int `json:"numWirelessUser"` + NumWirelessGuest int `json:"numWirelessGuest"` + Num2gUser int `json:"num2gUser"` + Num5gUser int `json:"num5gUser"` + Num6gUser int `json:"num6gUser"` + Num2gGuest int `json:"num2gGuest"` + Num5gGuest int `json:"num5gGuest"` + Num6gGuest int `json:"num6gGuest"` + Poor int `json:"poor"` + Fair int `json:"fair"` + NoData int `json:"noData"` + Good int `json:"good"` +} diff --git a/model/device.go b/model/device.go new file mode 100644 index 0000000..2063a88 --- /dev/null +++ b/model/device.go @@ -0,0 +1,45 @@ +package model + +import ( + "encoding/json" +) + +type Device struct { + MacAddress string `json:"mac"` + Name string `json:"name"` + Type DeviceType `json:"type"` + Subtype string `json:"subtype"` + DeviceSeriesType int `json:"deviceSeriesType"` + Model string `json:"model"` + IP string `json:"ip"` + IPv6 []string `json:"ipv6"` + Uptime string `json:"uptime"` + Status DeviceStatus `json:"status"` + LastSeen int `json:"lastSeen"` + CpuUtil int `json:"cpuUtil"` + MemUtil int `json:"memUtil"` + SerialNumber string `json:"sn"` + LicenseStatus int `json:"licenseStatus"` + TagName string `json:"tagName"` +} + +func (d Device) String() string { + data, _ := json.MarshalIndent(d, "", "\t") + return string(data) +} + +type DeviceStatus int + +const ( + DeviceStatusDisconnected DeviceStatus = iota + DeviceStatusConnected + DeviceStatusPending + DeviceStatusHeartbeatMissed + DeviceStatusIsolated +) + +type DeviceType string + +const ( + DeviceTypeAP DeviceType = "ap" +) diff --git a/model/login.go b/model/login.go new file mode 100644 index 0000000..ae2d791 --- /dev/null +++ b/model/login.go @@ -0,0 +1,26 @@ +package model + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse Response[LoginResponseResult] +type LoginResponseResult struct { + CsrfToken string `json:"csrfToken"` + SessionID string `json:"sessionId"` +} + +type AuthCodeResponse struct { + ErrorCode int `json:"errorCode"` + Message string `json:"msg"` + Result *string `json:"result"` +} + +type AuthTokenResponse Response[AuthTokenResponseResult] +type AuthTokenResponseResult struct { + AccessToken string `json:"accessToken"` + TokenType string `json:"tokenType"` + ExpiresIn int `json:"expiresIn"` + RefreshToken string `json:"refreshToken"` +} diff --git a/model/response.go b/model/response.go new file mode 100644 index 0000000..5f5b66e --- /dev/null +++ b/model/response.go @@ -0,0 +1,15 @@ +package model + +type Response[T any] struct { + ErrorCode int `json:"errorCode"` + Message string `json:"msg"` + Result T `json:"result"` +} + +type PagedResponse[T any] Response[PagedResponseResult[T]] +type PagedResponseResult[T any] struct { + TotalRows int `json:"totalRows"` + CurrentPage int `json:"currentPage"` + CurrentSize int `json:"currentSize"` + Data []T `json:"data"` +} diff --git a/model/site.go b/model/site.go new file mode 100644 index 0000000..f0d5209 --- /dev/null +++ b/model/site.go @@ -0,0 +1,22 @@ +package model + +import "encoding/json" + +type SiteID string +type Site struct { + ID SiteID `json:"siteId"` + Name string `json:"name"` + TagIDs []string `json:"tagIds"` + Region string `json:"region"` + TimeZone string `json:"timeZone"` + Scenario string `json:"scenario"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Address string `json:"address"` + Type int `json:"type,omitempty"` +} + +func (s Site) String() string { + data, _ := json.MarshalIndent(s, "", "\t") + return string(data) +} diff --git a/site.go b/site.go new file mode 100644 index 0000000..d4dfe18 --- /dev/null +++ b/site.go @@ -0,0 +1,59 @@ +package omadaapi + +import ( + "fmt" + "strconv" + + "git.tordarus.net/tordarus/ezhttp" + "git.tordarus.net/tordarus/omada-api/model" +) + +func (api *Api) GetSites() <-chan *model.Site { + out := make(chan *model.Site, 1000) + + go func() { + defer close(out) + + for page := 1; ; page++ { + resp, err := api.getSites(page) + if err != nil { + return + } + + for _, v := range resp.Result.Data { + out <- &v + } + + if resp.Result.CurrentPage*resp.Result.CurrentSize >= resp.Result.TotalRows { + break + } + } + }() + + return out +} + +func (api *Api) getSites(page int) (*model.PagedResponse[model.Site], error) { + req := ezhttp.Request( + ezhttp.Template(api.tmpl), + ezhttp.Method("GET"), + ezhttp.AppendPath(fmt.Sprintf("/openapi/v1/%s/sites", api.config.OmadaID)), + ezhttp.Query( + "page", strconv.Itoa(page), + "pageSize", "100", + ), + ) + + resp, err := ezhttp.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response, err := ezhttp.ParseJsonResponse[model.PagedResponse[model.Site]](resp.Body) + if err != nil { + return nil, err + } + + return response, nil +}