diff options
| author | xengineering <me@xengineering.eu> | 2025-12-09 21:05:08 +0100 |
|---|---|---|
| committer | xengineering <me@xengineering.eu> | 2025-12-09 21:05:08 +0100 |
| commit | 26c1040ffbd5ed10d5114d06d635cd1d882758f8 (patch) | |
| tree | 7ee216f8f624ea377b3d3ada49f066af713bd9e3 | |
| parent | 38740e3674eb9783074cadc9d794bacc94a9f766 (diff) | |
| download | homematic-go-26c1040ffbd5ed10d5114d06d635cd1d882758f8.tar homematic-go-26c1040ffbd5ed10d5114d06d635cd1d882758f8.tar.zst homematic-go-26c1040ffbd5ed10d5114d06d635cd1d882758f8.zip | |
homematic: Add initial state of library
| -rw-r--r-- | homematic/device.go | 92 | ||||
| -rw-r--r-- | homematic/xmlrpc.go | 147 | ||||
| -rw-r--r-- | homematic/xmlrpc_test.go | 44 |
3 files changed, 283 insertions, 0 deletions
diff --git a/homematic/device.go b/homematic/device.go new file mode 100644 index 0000000..fc7f383 --- /dev/null +++ b/homematic/device.go @@ -0,0 +1,92 @@ +package homematic + +import ( + "fmt" + "strconv" + + "github.com/beevik/etree" +) + +type Device struct { + Type string + Subtype string + Address string + Parent string + Version int +} + +func (d Device) String() string { + return fmt.Sprintf( + "Homematic device\n"+ + "TYPE: %s\n"+ + "SUBTYPE: %s\n"+ + "ADDRESS: %s\n"+ + "PARENT: %s\n"+ + "VERSION: %d", + d.Type, + d.Subtype, + d.Address, + d.Parent, + d.Version, + ) +} + +func (d *Device) LoadXML(doc *etree.Document) error { + types := doc.FindElements("/struct/member[name='TYPE']/value") + if len(types) != 1 { + return fmt.Errorf("Expected one type field but got %d.", len(types)) + } + + subtypes := doc.FindElements("/struct/member[name='SUBTYPE']/value") + if len(subtypes) != 1 { + return fmt.Errorf("Expected one subtype field but got %d.", len(subtypes)) + } + + addresses := doc.FindElements("/struct/member[name='ADDRESS']/value") + if len(addresses) != 1 { + return fmt.Errorf("Expected one address field but got %d.", len(addresses)) + } + + parents := doc.FindElements("/struct/member[name='PARENT']/value") + if len(parents) != 1 { + return fmt.Errorf("Expected one parent field but got %d.", len(parents)) + } + + versions := doc.FindElements("/struct/member[name='VERSION']/value/i4") + if len(versions) != 1 { + return fmt.Errorf("Expected one version field but got %d.", len(versions)) + } + + version, err := strconv.Atoi(versions[0].Text()) + if err != nil { + return fmt.Errorf( + "Cannot convert version value '%s' to an integer: %w", + versions[0].Text(), + err, + ) + } + + d.Type = types[0].Text() + d.Subtype = subtypes[0].Text() + d.Address = addresses[0].Text() + d.Parent = parents[0].Text() + d.Version = version + + return nil +} + +type Devices []Device + +func (devices Devices) String() string { + value := "" + + for index, device := range devices { + if index > 0 { + value = value + "\n" + } + + value = value + fmt.Sprintf("%v\n", device) + } + + return value +} diff --git a/homematic/xmlrpc.go b/homematic/xmlrpc.go new file mode 100644 index 0000000..e6d44af --- /dev/null +++ b/homematic/xmlrpc.go @@ -0,0 +1,147 @@ +package homematic + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strconv" + + "github.com/beevik/etree" +) + +type Requester struct { + addr string +} + +func NewRequester(addr string) Requester { + return Requester{ + addr: addr, + } +} + +func (req Requester) Request(request etree.Document) (*etree.Document, error) { + r, w := io.Pipe() + + go func() { + request.Indent(2) + _, err := request.WriteTo(w) + _ = w.CloseWithError(err) + }() + + response, err := http.Post(req.addr, "application/xml", r) + if err != nil { + return nil, fmt.Errorf("HTTP POST failed: %w", err) + } + + doc := etree.NewDocument() + _, err = doc.ReadFrom(response.Body) + if err != nil { + return nil, fmt.Errorf("Failed to decode response: %w", err) + } + + return doc, nil +} + +func (req Requester) ListDevices() (Devices, error) { + var devices Devices + + request := etree.NewDocument() + request.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`) + call := request.CreateElement("methodCall") + call.CreateElement("methodName").CreateText("listDevices") + call.CreateElement("params") + + response, err := req.Request(*request) + if err != nil { + return devices, fmt.Errorf("Could not request listDevices: %w", err) + } + + for _, dev := range response.FindElements("/methodResponse/params/param/value/array/data/value/struct") { + doc := etree.NewDocumentWithRoot(dev.Copy()) + + device := Device{} + device.LoadXML(doc) + + devices = append(devices, device) + } + + return devices, nil +} + +func (req Requester) GetValue(address string) (bool, error) { + request := etree.NewDocument() + request.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`) + methodCall := request.CreateElement("methodCall") + methodName := methodCall.CreateElement("methodName") + methodName.CreateText("getValue") + params := methodCall.CreateElement("params") + params.CreateElement("param").CreateElement("value").CreateElement("string").CreateText(address) + params.CreateElement("param").CreateElement("value").CreateElement("string").CreateText("STATE") + + response, err := req.Request(*request) + if err != nil { + return false, fmt.Errorf("Could not request getValue: %w", err) + } + + value := response.FindElement("/methodResponse/params/param/value/i4") + if value == nil { + return false, fmt.Errorf("Could not get value from response") + } + + integer, err := strconv.Atoi(value.Text()) + if err != nil { + return false, fmt.Errorf("Could not convert state value to int: %w", err) + } + + switch integer { + case 0: + return false, nil + case 1: + return true, nil + default: + return false, fmt.Errorf("Cannot cast integer %d to bool", integer) + } +} + +type Responder struct { + ip net.IP + port int + handler func(http.ResponseWriter, *http.Request) +} + +func NewResponder(ip net.IP, port int, handler func(http.ResponseWriter, *http.Request)) Responder { + return Responder{ + ip: ip, + port: port, + handler: handler, + } +} + +func (resp Responder) Start() (context.CancelFunc, int, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", resp.ip, resp.port)) + if err != nil { + return nil, 0, fmt.Errorf("Could not listen: %w", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + + m := http.NewServeMux() + m.HandleFunc("/", resp.handler) + + s := http.Server{ + Handler: m, + } + go s.Serve(listener) + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + <-ctx.Done() + s.Close() + listener.Close() + }() + + return cancel, port, nil +} diff --git a/homematic/xmlrpc_test.go b/homematic/xmlrpc_test.go new file mode 100644 index 0000000..732f6f8 --- /dev/null +++ b/homematic/xmlrpc_test.go @@ -0,0 +1,44 @@ +package homematic + +import ( + "fmt" + "io" + "net" + "net/http" + "testing" + + "github.com/beevik/etree" +) + +func TestRequesterResponder(t *testing.T) { + ip := net.ParseIP("127.0.0.1") + + resp := NewResponder(ip, 0, func(w http.ResponseWriter, r *http.Request) { + io.Copy(w, r.Body) + }) + + cancel, port, err := resp.Start() + if err != nil { + t.Fatalf("Could not start responder: %v", err) + } + defer cancel() + + req := NewRequester(fmt.Sprintf("http://%s:%d", ip, port)) + + doc := etree.NewDocument() + doc.CreateElement("foo").CreateText("bar") + + response, err := req.Request(*doc) + if err != nil { + t.Fatalf("Failed to execute request: %v", err) + } + + element := response.FindElement("/foo") + if element == nil { + t.Fatal("Could not find `foo` element.") + } + + if element.Text() != `bar` { + t.Fatalf("Expected text `bar` in element `foo` but got `%s`.", element.Text()) + } +} |
