Skip to content

Commit

Permalink
split into smaller files and simplify connection parameter handling.
Browse files Browse the repository at this point in the history
  • Loading branch information
ndecker committed Dec 10, 2022
1 parent 5356fa4 commit 693e1b8
Show file tree
Hide file tree
Showing 6 changed files with 528 additions and 528 deletions.
133 changes: 133 additions & 0 deletions collector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package main

import (
"fmt"
"sync"
"time"

upnp "github.com/ndecker/fritzbox_exporter/fritzbox_upnp"
"github.com/prometheus/client_golang/prometheus"
)

const serviceLoadRetryTime = 1 * time.Minute

var collectErrors = prometheus.NewCounter(prometheus.CounterOpts{
Name: "fritzbox_exporter_collect_errors",
Help: "Number of collection errors.",
})

type FritzboxCollector struct {
Parameters upnp.ConnectionParameters
sync.Mutex // protects Root
Root *upnp.Root
}

// LoadServices tries to load the service information. Retries until success.
func (fc *FritzboxCollector) LoadServices() {
for {
root, err := upnp.LoadServices(fc.Parameters)
if err != nil {
fmt.Printf("cannot load services: %s\n", err)

time.Sleep(serviceLoadRetryTime)
continue
}

fmt.Printf("services loaded\n")

fc.Lock()
fc.Root = root
fc.Unlock()
return
}
}

func (fc *FritzboxCollector) Describe(ch chan<- *prometheus.Desc) {
for _, m := range metrics {
ch <- m.Desc
}
}

func (fc *FritzboxCollector) Collect(ch chan<- prometheus.Metric) {
fc.Lock()
root := fc.Root
fc.Unlock()

if root == nil {
return // Services not loaded yet
}

var err error
var lastService string
var lastMethod string
var lastResult upnp.Result

for _, m := range metrics {
if m.Service != lastService || m.Action != lastMethod {
service, ok := root.Services[m.Service]
if !ok {
// TODO
fmt.Println("cannot find service", m.Service)
fmt.Println(root.Services)
continue
}
action, ok := service.Actions[m.Action]
if !ok {
// TODO
fmt.Println("cannot find action", m.Action)
continue
}

lastResult, err = action.Call()
if err != nil {
fmt.Println(err)
collectErrors.Inc()
continue
}
}

val, ok := lastResult[m.Result]
if !ok {
fmt.Println("result not found", m.Result)
collectErrors.Inc()
continue
}

floatVal, ok := toFloat(val, m.OkValue)
if !ok {
fmt.Println("cannot convert to float:", val)
collectErrors.Inc()
continue
}

ch <- prometheus.MustNewConstMetric(
m.Desc,
m.MetricType,
floatVal,
fc.Parameters.Device,
)
}
}

func toFloat(val any, okValue string) (float64, bool) {
switch tval := val.(type) {
case uint64:
return float64(tval), true
case bool:
if tval {
return 1, true
} else {
return 0, true
}
case string:
if tval == okValue {
return 1, true
} else {
return 0, true
}
default:
return 0, false

}

}
164 changes: 164 additions & 0 deletions fritzbox_upnp/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package fritzbox_upnp

import (
"bytes"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"

dac "github.com/123Haynes/go-http-digest-auth-client"
)

// An UPNP Acton on a service
type Action struct {
service *Service

Name string `xml:"name"`
Arguments []*Argument `xml:"argumentList>argument"`
ArgumentMap map[string]*Argument // Map of arguments indexed by .Name
}

// IsGetOnly returns if the action seems to be a query for information.
// This is determined by checking if the action has no input arguments and at least one output argument.
func (a *Action) IsGetOnly() bool {
for _, a := range a.Arguments {
if a.Direction == "in" {
return false
}
}
return len(a.Arguments) > 0
}

// An Argument to an action
type Argument struct {
Name string `xml:"name"`
Direction string `xml:"direction"`
RelatedStateVariable string `xml:"relatedStateVariable"`
StateVariable *StateVariable
}

// A state variable that can be manipulated through actions
type StateVariable struct {
Name string `xml:"name"`
DataType string `xml:"dataType"`
DefaultValue string `xml:"defaultValue"`
}

// The result of a Call() contains all output arguments of the call.
// The map is indexed by the name of the state variable.
// The type of the value is string, uint64 or bool depending of the DataType of the variable.
type Result map[string]interface{}

// Call an action.
// Currently only actions without input arguments are supported.
func (a *Action) Call() (Result, error) {
bodystr := fmt.Sprintf(`
<?xml version='1.0' encoding='utf-8'?>
<s:Envelope s:encodingStyle='http://schemas.xmlsoap.org/soap/encoding/' xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'>
<s:Body>
<u:%s xmlns:u='%s' />
</s:Body>
</s:Envelope>
`, a.Name, a.service.ServiceType)

url := a.service.Device.root.baseUrl + a.service.ControlUrl
body := strings.NewReader(bodystr)

req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, err
}

action := fmt.Sprintf("%s#%s", a.service.ServiceType, a.Name)

req.Header.Set("Content-Type", textXml)
req.Header.Set("SoapAction", action)

t := dac.NewTransport(a.service.Device.root.params.Username, a.service.Device.root.params.Password)

resp, err := t.RoundTrip(req)
if err != nil {
log.Fatalln(err)
}
defer closeIgnoringError(resp.Body)

if resp.StatusCode == 401 {
return nil, fmt.Errorf("cannot read service %s: status 401 unauthorized", a.Name)
}

data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read request body: %w", err)
}

return a.parseSoapResponse(data)

}

func (a *Action) parseSoapResponse(data []byte) (Result, error) {
res := make(Result)
dec := xml.NewDecoder(bytes.NewReader(data))

for {
t, err := dec.Token()
if err == io.EOF {
return res, nil
}

if err != nil {
return nil, fmt.Errorf("cannot parse soap response: %w; body: %s", err, data)
}

if se, ok := t.(xml.StartElement); ok {
arg, ok := a.ArgumentMap[se.Name.Local]

if ok {
t2, err := dec.Token()
if err != nil {
return nil, err
}

var val string
switch element := t2.(type) {
case xml.EndElement:
val = ""
case xml.CharData:
val = string(element)
default:
return nil, ErrInvalidSOAPResponse
}

converted, err := convertResult(val, arg)
if err != nil {
return nil, err
}
res[arg.StateVariable.Name] = converted
}
}

}
}

func convertResult(val string, arg *Argument) (interface{}, error) {
switch arg.StateVariable.DataType {
case "string":
return val, nil
case "boolean":
return val == "1", nil

case "ui1", "ui2", "ui4":
// type ui4 can contain values greater than 2^32!
res, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return nil, err
}
return res, nil
default:
return nil, fmt.Errorf("unknown datatype: %s", arg.StateVariable.DataType)

}
}
Loading

0 comments on commit 693e1b8

Please sign in to comment.