From 821a37f9750bb705ed5fe50dceb35a3187663de9 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 12 Jul 2025 12:28:13 +0200 Subject: [PATCH] change a lot --- .gitignore | 1 + cmd/pmomusic/main.go | 63 +-- internal/netutils/ip_detect.go | 92 ---- internal/netutils/list_all_ip.go | 49 -- internal/renderer/renderer.go | 1 - internal/soap/soap.go | 69 --- internal/ssdp/responder.go | 115 ---- internal/ssdp/ssdp.go | 35 -- internal/ssdp/uuid.go | 42 -- internal/upnp/description_xml.go | 72 --- internal/upnp/device.go | 93 ---- internal/upnp/html/index.html | 0 internal/upnp/server.go | 64 --- internal/upnp/service.go | 115 ---- internal/upnp/statevalueinstance.go | 29 -- internal/upnp/valuerange.go | 15 - internal/upnp/xml/AVTransport.xml | 81 --- internal/upnp/xml/ConnectionManager.xml | 31 -- internal/upnp/xml/Info.xml | 111 ---- internal/upnp/xml/Playlist.xml | 89 ---- internal/upnp/xml/Product.xml | 86 --- internal/upnp/xml/RenderingControl.xml | 43 -- upnp/events.go | 5 + {internal/upnp => upnp}/statevalue.go | 256 ++++++--- upnp/statevalueinstance.go | 75 +++ {internal/upnp => upnp}/statevaluetype.go | 609 +++++----------------- upnp/utils_bool.go | 49 ++ upnp/utils_float.go | 77 +++ upnp/utils_int.go | 116 +++++ upnp/utils_numeric_operandes.go | 30 ++ upnp/utils_uint.go | 100 ++++ upnp/valuerange.go | 61 +++ 32 files changed, 880 insertions(+), 1794 deletions(-) delete mode 100644 internal/netutils/ip_detect.go delete mode 100644 internal/netutils/list_all_ip.go delete mode 100644 internal/renderer/renderer.go delete mode 100644 internal/soap/soap.go delete mode 100644 internal/ssdp/responder.go delete mode 100644 internal/ssdp/ssdp.go delete mode 100644 internal/ssdp/uuid.go delete mode 100644 internal/upnp/description_xml.go delete mode 100644 internal/upnp/device.go delete mode 100644 internal/upnp/html/index.html delete mode 100644 internal/upnp/server.go delete mode 100644 internal/upnp/service.go delete mode 100644 internal/upnp/statevalueinstance.go delete mode 100644 internal/upnp/valuerange.go delete mode 100644 internal/upnp/xml/AVTransport.xml delete mode 100644 internal/upnp/xml/ConnectionManager.xml delete mode 100644 internal/upnp/xml/Info.xml delete mode 100644 internal/upnp/xml/Playlist.xml delete mode 100644 internal/upnp/xml/Product.xml delete mode 100644 internal/upnp/xml/RenderingControl.xml create mode 100644 upnp/events.go rename {internal/upnp => upnp}/statevalue.go (57%) create mode 100644 upnp/statevalueinstance.go rename {internal/upnp => upnp}/statevaluetype.go (56%) create mode 100644 upnp/utils_bool.go create mode 100644 upnp/utils_float.go create mode 100644 upnp/utils_int.go create mode 100644 upnp/utils_numeric_operandes.go create mode 100644 upnp/utils_uint.go create mode 100644 upnp/valuerange.go diff --git a/.gitignore b/.gitignore index d61d5b0d..faf81a59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /vendor/ **/*.log **/*.old +xxx diff --git a/cmd/pmomusic/main.go b/cmd/pmomusic/main.go index e66413bc..d6e26187 100644 --- a/cmd/pmomusic/main.go +++ b/cmd/pmomusic/main.go @@ -1,41 +1,46 @@ package main import ( - log "github.com/sirupsen/logrus" + "context" + "log" + "os" + "os/signal" + "syscall" + "time" - "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/netutils" - "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp" + "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/renderer" "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp" ) func main() { - localIP, err := netutils.GuessLocalIP() + // Crée le serveur avec baseURL auto-déduite depuis l’IP locale + server := upnp.NewServer("PMO Music Server", "PMO Factory", "Fake Server", "", 1400) - if err != nil { - log.Fatalf("Could not guess local IP: %v", err) - } - - usn := ssdp.GetUUID() - - desc := upnp.NewDevice(localIP, 1400, usn, "pmomusic Fake Renderer", "pmomusic") - - desc.RegisterService("urn:schemas-upnp-org:service:AVTransport:1") - desc.RegisterService("urn:schemas-upnp-org:service:RenderingControl:1") - desc.RegisterService("urn:schemas-upnp-org:service:ConnectionManager:1") - - desc.RegisterService("urn:av-openhome-org:service:Product:1") - desc.RegisterService("urn:av-openhome-org:service:Playlist:1") - desc.RegisterService("urn:av-openhome-org:service:Info:1") - - location := "http://" + localIP + ":1400/description.xml" - log.Printf("Server UUID: %s", usn) - - log.Info(desc.GenerateXML()) - // Démarre la diffusion SSDP - go ssdp.AnnounceRenderer(desc) - go ssdp.StartSSDPResponder(desc) + // Crée le renderer UPnP + rendererDevice := renderer.NewMusicRenderer("pmomusic Fake Renderer", "pmomusic", "fake model") + server.RegisterDevice("MusicRenderer", rendererDevice) // Lance le serveur HTTP - log.Println("Starting HTTP server at:", location) - upnp.StartHTTPServer(desc) + if err := server.Start(); err != nil { + log.Fatalf("Failed to start UPnP server: %v", err) + } + + // Gère les signaux pour un arrêt propre + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + log.Println("UPnP MusicRenderer is running... Press Ctrl+C to stop.") + <-sigs + + log.Println("Shutting down...") + + // Dé-annonce SSDP (optionnel) + server.NotifyByeBye() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := server.Stop(ctx); err != nil { + log.Printf("Error shutting down UPnP server: %v", err) + } } diff --git a/internal/netutils/ip_detect.go b/internal/netutils/ip_detect.go deleted file mode 100644 index 7c89067e..00000000 --- a/internal/netutils/ip_detect.go +++ /dev/null @@ -1,92 +0,0 @@ -package netutils - -import ( - "errors" - "net" - "sort" - "strings" -) - -// GuessLocalIP returns the best-guess local IP address usable for UPnP location, -// in order of preference: eth0 > en* > wl* > any private, non-loopback IP. -func GuessLocalIP() (string, error) { - ifaces, err := net.Interfaces() - if err != nil { - return "", err - } - - type scoredIP struct { - ip net.IP - score int - } - - var candidates []scoredIP - - for _, iface := range ifaces { - // Skip interfaces that are down or loopback - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { - continue - } - - addrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, addr := range addrs { - var ip net.IP - - switch v := addr.(type) { - case *net.IPNet: - ip = v.IP - case *net.IPAddr: - ip = v.IP - } - - // Skip non-IPv4 or non-private addresses - if ip == nil || ip.IsLoopback() || ip.To4() == nil || !isPrivateIPv4(ip) { - continue - } - - score := scoreInterfaceName(iface.Name) - candidates = append(candidates, scoredIP{ip: ip, score: score}) - } - } - - if len(candidates) == 0 { - return "", errors.New("no suitable local IP found") - } - - // Prefer interfaces with higher score - sort.SliceStable(candidates, func(i, j int) bool { - return candidates[i].score > candidates[j].score - }) - - return candidates[0].ip.String(), nil -} - -func isPrivateIPv4(ip net.IP) bool { - private := []string{ - "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.2", "192.168.", - } - ipStr := ip.String() - for _, p := range private { - if strings.HasPrefix(ipStr, p) { - return true - } - } - return false -} - -func scoreInterfaceName(name string) int { - switch { - case name == "eth0": - return 100 - case strings.HasPrefix(name, "en"): - return 80 - case name == "wlan0" || strings.HasPrefix(name, "wl"): - return 60 - default: - return 10 - } -} diff --git a/internal/netutils/list_all_ip.go b/internal/netutils/list_all_ip.go deleted file mode 100644 index 19240745..00000000 --- a/internal/netutils/list_all_ip.go +++ /dev/null @@ -1,49 +0,0 @@ -package netutils - -import ( - "net" -) - -// ListAllIPs returns a map of interface names to their associated IPv4 addresses. -func ListAllIPs() map[string][]string { - result := make(map[string][]string) - - ifaces, err := net.Interfaces() - if err != nil { - result["error"] = []string{err.Error()} - return result - } - - for _, iface := range ifaces { - if iface.Flags&net.FlagUp == 0 { - continue // Ignore down interfaces - } - - addrs, err := iface.Addrs() - if err != nil { - continue - } - - var ips []string - for _, addr := range addrs { - var ip net.IP - switch v := addr.(type) { - case *net.IPNet: - ip = v.IP - case *net.IPAddr: - ip = v.IP - } - - if ip == nil || ip.To4() == nil || ip.IsLoopback() { - continue - } - ips = append(ips, ip.String()) - } - - if len(ips) > 0 { - result[iface.Name] = ips - } - } - - return result -} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go deleted file mode 100644 index 35803f64..00000000 --- a/internal/renderer/renderer.go +++ /dev/null @@ -1 +0,0 @@ -package renderer diff --git a/internal/soap/soap.go b/internal/soap/soap.go deleted file mode 100644 index ca536cd1..00000000 --- a/internal/soap/soap.go +++ /dev/null @@ -1,69 +0,0 @@ -package soap - -import ( - "bytes" - "encoding/xml" - "io" - "log" - "net/http" -) - -func HandleSOAP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - log.Println("Failed to read SOAP request:", err) - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - defer r.Body.Close() - - log.Println("Received SOAP request:") - log.Println(string(body)) - - // Extract action name from the SOAP body - action := extractSOAPAction(body) - log.Printf("SOAP Action: %s\n", action) - - // Send dummy response - w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"") - w.WriteHeader(http.StatusOK) - w.Write([]byte(dummySOAPResponse(action))) -} - -func extractSOAPAction(body []byte) string { - type Envelope struct { - Body struct { - XMLName xml.Name - } `xml:"Body"` - } - - var env Envelope - if err := xml.Unmarshal(body, &env); err != nil { - log.Println("SOAP parse error:", err) - return "UnknownAction" - } - - decoder := xml.NewDecoder(bytes.NewReader(body)) - for { - tok, err := decoder.Token() - if err != nil { - break - } - if se, ok := tok.(xml.StartElement); ok && se.Name.Local != "Envelope" && se.Name.Local != "Body" { - return se.Name.Local - } - } - return "UnknownAction" -} - -func dummySOAPResponse(action string) string { - // You can specialize per action if needed. - return ` - - - - - -` -} diff --git a/internal/ssdp/responder.go b/internal/ssdp/responder.go deleted file mode 100644 index 69d0d7b7..00000000 --- a/internal/ssdp/responder.go +++ /dev/null @@ -1,115 +0,0 @@ -package ssdp - -import ( - "fmt" - "log" - "net" - "strconv" - "strings" - "time" - - "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp" -) - -const ( - ssdpAddr = "239.255.255.250:1900" - serviceType = "urn:schemas-upnp-org:device:MediaRenderer:1" -) - -func fromUDPAddress(src *net.UDPAddr) string { - from := src.String() - - hostnames, err := net.LookupAddr(src.IP.String()) - if err == nil { - from = strings.TrimSuffix(hostnames[0], ".") + ":" + strconv.Itoa(src.Port) - } - - return from -} - -// StartSSDPResponder listens for M-SEARCH requests and responds if they match -func StartSSDPResponder(device *upnp.DeviceDescription) { - addr, err := net.ResolveUDPAddr("udp4", ssdpAddr) - if err != nil { - log.Fatal("Failed to resolve SSDP address:", err) - } - - conn, err := net.ListenMulticastUDP("udp4", nil, addr) - if err != nil { - log.Fatal("Failed to listen for SSDP:", err) - } - defer conn.Close() - - conn.SetReadBuffer(2048) - - buf := make([]byte, 2048) - log.Println("🔍 Listening for M-SEARCH...") - - for { - n, src, err := conn.ReadFromUDP(buf) - if err != nil { - log.Println("ReadFromUDP error:", err) - continue - } - - data := string(buf[:n]) - if strings.HasPrefix(data, "M-SEARCH * HTTP/1.1") { - st := extractHeader(data, "ST") - - log.Printf("🔎 M-SEARCH from %s, ST=%s\n", fromUDPAddress(src), st) - - if st == "ssdp:all" || - st == serviceType || - strings.HasPrefix(st, "urn:schemas-upnp-org:service:") || - strings.HasPrefix(st, "urn:av-openhome-org:service:") || - strings.HasPrefix(st, "urn:bubblesoftapps-com:service:") { - - go sendSSDPResponse(src, device, st) - } - } - } -} - -func sendSSDPResponse(dst *net.UDPAddr, device *upnp.DeviceDescription, st string) { - resp := fmt.Sprintf( - "HTTP/1.1 200 OK\r\n"+ - "CACHE-CONTROL: max-age=1800\r\n"+ - "DATE: %s\r\n"+ - "EXT:\r\n"+ - "LOCATION: %s:%d\r\n"+ - "SERVER: pmomusic/1.0 UPnP/1.1 DLNARenderer/1.0\r\n"+ - "ST: %s\r\n"+ - "USN: %s::%s\r\n"+ - "\r\n", - time.Now().Format(time.RFC1123), - device.IP, device.Port, - st, - device.USN, - st, - ) - - // envoie UDP - conn, err := net.DialUDP("udp4", nil, dst) - if err != nil { - log.Println("Failed to dial UDP to respond:", err) - return - } - defer conn.Close() - - _, err = conn.Write([]byte(resp)) - if err != nil { - log.Println("Failed to send SSDP response:", err) - } -} - -func extractHeader(data string, key string) string { - lines := strings.Split(data, "\r\n") - key = strings.ToLower(key) - for _, line := range lines { - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 && strings.ToLower(strings.TrimSpace(parts[0])) == key { - return strings.TrimSpace(parts[1]) - } - } - return "" -} diff --git a/internal/ssdp/ssdp.go b/internal/ssdp/ssdp.go deleted file mode 100644 index e1b9e5a8..00000000 --- a/internal/ssdp/ssdp.go +++ /dev/null @@ -1,35 +0,0 @@ -package ssdp - -import ( - "log" - "strconv" - "time" - - "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp" - "github.com/koron/go-ssdp" -) - -func AnnounceRenderer(device *upnp.DeviceDescription) { - st := "urn:schemas-upnp-org:device:MediaRenderer:1" - server := "pmomusic/1.0 UPnP/1.1 DLNARenderer/1.0" - maxAge := 1800 - - _, err := ssdp.Advertise(st, device.USN+"::"+st, device.IP+":"+strconv.Itoa(int(device.Port)), server, maxAge) - if err != nil { - log.Println("SSDP advertise error:", err) - return - } - - log.Println("✅ SSDP advertisement started") - - // Ce `Advertiser` fait automatiquement le NOTIFY loop. - // Il est censé continuer à diffuser les `alive` tous les MaxAge / 2. - - // Exemple : on attend indéfiniment - for { - time.Sleep(time.Hour) - } - - // Un jour on voudra faire ça à l'arrêt : - // adv.Close() // envoie le ssdp:byebye -} diff --git a/internal/ssdp/uuid.go b/internal/ssdp/uuid.go deleted file mode 100644 index 67fe3c85..00000000 --- a/internal/ssdp/uuid.go +++ /dev/null @@ -1,42 +0,0 @@ -package ssdp - -import ( - "os" - "path/filepath" - - "github.com/google/uuid" -) - -var __UUID = "" - -func loadOrCreateUUID() (string, error) { - configDir, err := os.UserConfigDir() - if err != nil { - return "", err - } - filePath := filepath.Join(configDir, "pmomusic", "uuid.txt") - os.MkdirAll(filepath.Dir(filePath), 0755) - - if data, err := os.ReadFile(filePath); err == nil && len(data) > 0 { - return string(data), nil - } - - id := "uuid:pmo-" + uuid.New().String() - if err := os.WriteFile(filePath, []byte(id), 0600); err != nil { - return "", err - } - return id, nil -} - -func GetUUID() string { - if __UUID == "" { - u, err := loadOrCreateUUID() - - if err != nil { - panic(err) - } - - __UUID = u - } - return __UUID -} diff --git a/internal/upnp/description_xml.go b/internal/upnp/description_xml.go deleted file mode 100644 index b0c38579..00000000 --- a/internal/upnp/description_xml.go +++ /dev/null @@ -1,72 +0,0 @@ -package upnp - -import ( - "fmt" -) - -func generateDeviceDescription(device *DeviceDescription) string { - return fmt.Sprintf(` - - - 1 - 0 - - http://%s:%d/ - - urn:schemas-upnp-org:device:MediaRenderer:1 - pmomusic Fake Renderer - GoDLNA - http://example.com - Fake DLNA Renderer - pmomusic - 1.0 - http://example.com/model - %s - http://%s:%d - - - urn:schemas-upnp-org:service:AVTransport:1 - urn:upnp-org:serviceId:AVTransport - /upnp/control/AVTransport - /upnp/event/AVTransport - /scpd/AVTransport.xml - - - urn:schemas-upnp-org:service:RenderingControl:1 - urn:upnp-org:serviceId:RenderingControl - /upnp/control/RenderingControl - /upnp/event/RenderingControl - /scpd/RenderingControl.xml - - - urn:schemas-upnp-org:service:ConnectionManager:1 - urn:upnp-org:serviceId:ConnectionManager - /upnp/control/ConnectionManager - /upnp/event/ConnectionManager - /scpd/ConnectionManager.xml - - - urn:av-openhome-org:service:Product:1 - urn:av-openhome-org:serviceId:Product - /upnp/control/Product - /upnp/event/Product - /scpd/Product.xml - - - urn:av-openhome-org:service:Playlist:1 - urn:av-openhome-org:serviceId:Playlist - /upnp/control/Playlist - /upnp/event/Playlist - /scpd/Playlist.xml - - - urn:av-openhome-org:service:Info:1 - urn:av-openhome-org:serviceId:Info - /upnp/control/Info - /upnp/event/Info - /scpd/Info.xml - - - -`, device.IP, device.Port, device.USN, device.IP, device.Port) -} diff --git a/internal/upnp/device.go b/internal/upnp/device.go deleted file mode 100644 index 48ebe4c1..00000000 --- a/internal/upnp/device.go +++ /dev/null @@ -1,93 +0,0 @@ -package upnp - -import ( - "bytes" - "text/template" - - log "github.com/sirupsen/logrus" -) - -type DeviceDescription struct { - IP string - Port uint - USN string - Friendly string - ModelName string - Services *ServiceList -} - -const deviceTemplate = ` - - - 1 - 0 - - http://{{.IP}}:{{.Port}}/ - - urn:schemas-upnp-org:device:MediaRenderer:1 - {{.Friendly}} - GoDLNA - http://example.com - Fake DLNA Renderer - {{.ModelName}} - 1.0 - http://example.com/model - {{.USN}} - http://{{.IP}}:{{.Port}}/ - {{.ServicesXML}} - -` - -func NewDevice(ip string, port uint, usn string, friendly string, modelname string) *DeviceDescription { - services := NewServiceList() - return &DeviceDescription{ - IP: ip, - Port: port, - USN: usn, - Friendly: friendly, - ModelName: modelname, - Services: services, - } -} - -func (d *DeviceDescription) RegisterService(service string) { - d.Services.Append(service) -} - -func (d *DeviceDescription) GenerateXML() (string, error) { - servicesXML, err := d.Services.GenerateServiceListXML() - if err != nil { - log.Errorf("Failed to generate service list XML: %v", err) - return "", err - } - - tmpl, err := template.New("device").Parse(deviceTemplate) - if err != nil { - log.Errorf("Failed to parse device description template: %v", err) - return "", err - } - - data := struct { - IP string - Port uint - USN string - Friendly string - ModelName string - ServicesXML string // trusted raw XML - }{ - IP: d.IP, - Port: d.Port, - USN: d.USN, - Friendly: d.Friendly, - ModelName: d.ModelName, - ServicesXML: servicesXML, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - log.Errorf("Failed to execute template: %v", err) - return "", err - } - - return buf.String(), nil -} diff --git a/internal/upnp/html/index.html b/internal/upnp/html/index.html deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/upnp/server.go b/internal/upnp/server.go deleted file mode 100644 index ccfa8def..00000000 --- a/internal/upnp/server.go +++ /dev/null @@ -1,64 +0,0 @@ -package upnp - -import ( - "embed" - "fmt" - "io/fs" - "log" - "net/http" - - "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap" -) - -//go:embed xml/*.xml -var embeddedXML embed.FS - -//go:embed html/*.html -var embeddedHTML embed.FS - -// ServeStaticXML mounts handlers for SCPD XML files. -func ServeStaticXML(mux *http.ServeMux) { - subFS, err := fs.Sub(embeddedXML, "xml") - if err != nil { - panic("failed to create sub FS: " + err.Error()) - } - mux.Handle("/scpd/", http.StripPrefix("/scpd/", http.FileServer(http.FS(subFS)))) -} - -func ServeStaticHTML(mux *http.ServeMux) { - subFS, err := fs.Sub(embeddedHTML, "html") - if err != nil { - panic("failed to create sub FS: " + err.Error()) - } - mux.Handle("/", http.FileServer(http.FS(subFS))) -} - -func StartHTTPServer(device *DeviceDescription) { - mux := http.NewServeMux() - - // Device description - mux.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/xml") - xml, err := device.GenerateXML() - if err != nil { - w.WriteHeader(500) - w.Write([]byte(err.Error())) - return - } - - w.Write([]byte(xml)) - }) - - // SOAP control endpoints - mux.HandleFunc("/upnp/control/AVTransport", soap.HandleSOAP) - mux.HandleFunc("/upnp/control/RenderingControl", soap.HandleSOAP) - mux.HandleFunc("/upnp/control/ConnectionManager", soap.HandleSOAP) - - // Serve static SCPD XML files - ServeStaticXML(mux) - ServeStaticHTML(mux) - - addr := fmt.Sprintf("%s:%d", device.IP, device.Port) - log.Printf("Serving UPnP fake renderer at http://%s", addr) - log.Fatal(http.ListenAndServe(addr, mux)) -} diff --git a/internal/upnp/service.go b/internal/upnp/service.go deleted file mode 100644 index b655c313..00000000 --- a/internal/upnp/service.go +++ /dev/null @@ -1,115 +0,0 @@ -package upnp - -import ( - "bytes" - "fmt" - "strings" - "text/template" -) - -type ServiceGroup string - -const ( - UPnP ServiceGroup = "upnp" - OpenHome ServiceGroup = "openhome" - Other ServiceGroup = "other" -) - -type ServiceDescription struct { - Group ServiceGroup - Type string - Name string - ID string - URLPrefix string - XML string // auto-rempli temporairement pour le template parent -} - -const serviceTemplate = ` - - {{.Type}} - {{.ID}} - {{.URLPrefix}}/control - {{.URLPrefix}}/event - {{.URLPrefix}}.xml - -` - -func NewServiceDescription(serviceType string) *ServiceDescription { - parts := strings.Split(serviceType, ":") - name := parts[len(parts)-2] - - var group ServiceGroup - switch { - case strings.Contains(serviceType, "schemas-upnp-org"): - group = UPnP - case strings.Contains(serviceType, "av-openhome-org"): - group = OpenHome - default: - group = Other - } - - id := fmt.Sprintf("urn:%s:serviceId:%s", string(group), name) - urlPrefix := fmt.Sprintf("/%s/%s", group, name) - - return &ServiceDescription{ - Group: group, - Type: serviceType, - Name: name, - ID: id, - URLPrefix: urlPrefix, - } -} - -func (desc *ServiceDescription) GenerateServiceXML() (string, error) { - tmpl, err := template.New("service").Parse(strings.TrimSpace(serviceTemplate)) - if err != nil { - return "", err - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, desc); err != nil { - return "", err - } - return buf.String(), nil -} - -type ServiceList struct { - Services []*ServiceDescription -} - -func NewServiceList() *ServiceList { - return &ServiceList{ - Services: make([]*ServiceDescription, 0), - } -} - -func (sl *ServiceList) Append(serviceType string) { - svc := NewServiceDescription(serviceType) - sl.Services = append(sl.Services, svc) -} - -func (sl *ServiceList) GenerateServiceListXML() (string, error) { - const listTemplate = ` - -{{range .Services}}{{.XML}} -{{end}}` - - // Génère tous les XML individuellement - for _, svc := range sl.Services { - xml, err := svc.GenerateServiceXML() - if err != nil { - return "", err - } - svc.XML = xml - } - - // Template principal - tmpl, err := template.New("list").Parse(strings.TrimSpace(listTemplate)) - if err != nil { - return "", err - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, sl); err != nil { - return "", err - } - return buf.String(), nil -} diff --git a/internal/upnp/statevalueinstance.go b/internal/upnp/statevalueinstance.go deleted file mode 100644 index b26854fd..00000000 --- a/internal/upnp/statevalueinstance.go +++ /dev/null @@ -1,29 +0,0 @@ -package upnp - -import "time" - -type StateValueInstance struct { - model *StateValue - value interface{} - previousValue interface{} - lastChange time.Time - lastEvent time.Time -} - -func (instance *StateValueInstance) Model() *StateValue { - return instance.model -} - -func (instance *StateValueInstance) Value() interface{} { - return instance.value -} - -// ShouldTriggerEvent vérifie toutes les conditions -func (instance *StateValueInstance) ShouldTriggerEvent() bool { - for _, condition := range instance.model.eventConditions { - if !condition(instance) { - return false - } - } - return true -} diff --git a/internal/upnp/valuerange.go b/internal/upnp/valuerange.go deleted file mode 100644 index 8639e01a..00000000 --- a/internal/upnp/valuerange.go +++ /dev/null @@ -1,15 +0,0 @@ -package upnp - -// ValueRange represents an inclusive range constraint for a state variable value. -// It defines the minimum and maximum allowable values for a given UPnP type. -// -// Usage: -// - For numeric types: min/max must be numeric types -// - For time types: min/max must be time.Time values -// - For strings/UUIDs: min/max must be string values -// -// Use with StateVarType.InRange() to check if values fall within the range. -type ValueRange struct { - min interface{} - max interface{} -} diff --git a/internal/upnp/xml/AVTransport.xml b/internal/upnp/xml/AVTransport.xml deleted file mode 100644 index 725d8d31..00000000 --- a/internal/upnp/xml/AVTransport.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - 1 - 0 - - - - SetAVTransportURI - - - InstanceID - in - A_ARG_TYPE_InstanceID - - - CurrentURI - in - AVTransportURI - - - CurrentURIMetaData - in - AVTransportURIMetaData - - - - - - Play - - - InstanceID - in - A_ARG_TYPE_InstanceID - - - Speed - in - TransportPlaySpeed - - - - - - Stop - - - InstanceID - in - A_ARG_TYPE_InstanceID - - - - - - - - A_ARG_TYPE_InstanceID - ui4 - - - - AVTransportURI - string - - - - AVTransportURIMetaData - string - - - - TransportPlaySpeed - string - - 1 - - - - diff --git a/internal/upnp/xml/ConnectionManager.xml b/internal/upnp/xml/ConnectionManager.xml deleted file mode 100644 index d12f286e..00000000 --- a/internal/upnp/xml/ConnectionManager.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - 10 - - - GetProtocolInfo - - - Source - out - SourceProtocolInfo - - - Sink - out - SinkProtocolInfo - - - - - - - SourceProtocolInfo - string - - - SinkProtocolInfo - string - - - diff --git a/internal/upnp/xml/Info.xml b/internal/upnp/xml/Info.xml deleted file mode 100644 index 29e82e9c..00000000 --- a/internal/upnp/xml/Info.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - 1 - 0 - - - - - Track - - - Uri - out - TrackUri - - - Metadata - out - TrackMetadata - - - - - - Details - - - Duration - out - TrackDuration - - - BitRate - out - TrackBitRate - - - BitDepth - out - TrackBitDepth - - - SampleRate - out - TrackSampleRate - - - Lossless - out - TrackLossless - - - CodecName - out - TrackCodecName - - - - - - Metatext - - - Value - out - Metatext - - - - - - - - TrackUri - string - - - TrackMetadata - string - - - TrackDuration - ui4 - - - TrackBitRate - ui4 - - - TrackBitDepth - ui4 - - - TrackSampleRate - ui4 - - - TrackLossless - boolean - - - TrackCodecName - string - - - Metatext - string - - - diff --git a/internal/upnp/xml/Playlist.xml b/internal/upnp/xml/Playlist.xml deleted file mode 100644 index 3ee06982..00000000 --- a/internal/upnp/xml/Playlist.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - 1 - 0 - - - - - Play - - - Value - in - TransportState - - - - - - Pause - - - - Stop - - - - Next - - - - Previous - - - - Insert - - - AfterId - in - TrackId - - - Uri - in - Uri - - - Metadata - in - Metadata - - - NewId - out - TrackId - - - - - - - - TransportState - string - - Playing - Paused - Stopped - - - - - Uri - string - - - - Metadata - string - - - - TrackId - ui4 - - - diff --git a/internal/upnp/xml/Product.xml b/internal/upnp/xml/Product.xml deleted file mode 100644 index a56f5f4f..00000000 --- a/internal/upnp/xml/Product.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - 1 - 0 - - - - - Manufacturer - - - Value - out - Manufacturer - - - - - - Model - - - Value - out - Model - - - - - - Product - - - Value - out - Product - - - - - - Standby - - - Value - in - Standby - - - - - - SetStandby - - - Value - in - Standby - - - - - - - - Manufacturer - string - - - - Model - string - - - - Product - string - - - - Standby - boolean - - - diff --git a/internal/upnp/xml/RenderingControl.xml b/internal/upnp/xml/RenderingControl.xml deleted file mode 100644 index 91fe5067..00000000 --- a/internal/upnp/xml/RenderingControl.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - 10 - - - SetVolume - - - InstanceID - in - A_ARG_TYPE_InstanceID - - - Channel - in - A_ARG_TYPE_Channel - - - DesiredVolume - in - Volume - - - - - - - A_ARG_TYPE_InstanceID - ui4 - - - A_ARG_TYPE_Channel - string - - Master - - - - Volume - ui2 - - - diff --git a/upnp/events.go b/upnp/events.go new file mode 100644 index 00000000..ee8d0b4c --- /dev/null +++ b/upnp/events.go @@ -0,0 +1,5 @@ +package upnp + +import "github.com/beevik/etree" + +type UpnpEvent *etree.Element diff --git a/internal/upnp/statevalue.go b/upnp/statevalue.go similarity index 57% rename from internal/upnp/statevalue.go rename to upnp/statevalue.go index b40f19d5..e83a2b90 100644 --- a/internal/upnp/statevalue.go +++ b/upnp/statevalue.go @@ -1,75 +1,39 @@ package upnp import ( + "encoding/base64" + "encoding/hex" "fmt" + "net/url" "reflect" "slices" "strings" "time" + "github.com/beevik/etree" + "github.com/google/uuid" log "github.com/sirupsen/logrus" ) type EventType string -const ( - EventEnabled EventType = "yes" - EventDisabled EventType = "no" - EventConditional EventType = "conditional" -) - type StateConditionFunc func(instance *StateValueInstance) bool +type StringValueParser func(value string) (interface{}, error) +type ValueSerializer func(value interface{}) (string, error) -type TypeModifier int - -const ( - ModifierAtomic TypeModifier = iota // Valeur simple (par défaut) - ModifierList // Liste d'éléments - ModifierMap // Tableau associatif - ModifierStruct // Structure nommée -) - -// StateValue represents a UPnP state variable with value constraints and eventing capabilities. -// It encapsulates: -// - Value type (StateVarType) -// - Value range constraints (min/max) -// - Allowed value list -// - Eventing configuration -// graph TD -// A[StateValue] --> B[baseType: Type sérialisé] -// A --> C[modifier: Structure des données] -// C --> D[Liste] -// C --> E[Map] -// C --> F[Structure] -// D --> G[elementType: Type des éléments] -// E --> H[keyType: Type des clés] -// E --> I[elementType: Type des valeurs] -// -// Usage: -// -// state := StateValue{ -// name: "Volume", -// baseType: StateType_UI2, -// } -// state.SetRange(0, 100) // Set 0-100 range -// state.AppendAllowedValue(25, 50, 75) // Add specific allowed values type StateValue struct { - name string - baseType StateVarType // Renommé pour plus de clarté - modifier TypeModifier - step interface{} - minDelta interface{} + name string // Name of the state value (e.g., "Volume", "Brightness") + valueType StateVarType // Type of state value, see the upnp/statevaluetype package for more information on this. + step interface{} // Step size for incremental state values (e.g., "10") modifiable bool - eventConditions []StateConditionFunc + eventConditions map[string]StateConditionFunc description string defaultValue interface{} valueRange *ValueRange allowedValues []interface{} sendEvents bool - elementType StateVarType // Pour listes et valeurs de maps - keyType StateVarType // Type des clés (pour les maps) - structFields map[string]StateVarType // Champs (pour les structures) - + parse StringValueParser + marshal ValueSerializer } // Name returns the state variable's name (e.g., "Volume", "Brightness"). @@ -79,21 +43,57 @@ func (state *StateValue) Name() string { // Type returns the UPnP data type of the state variable. func (state *StateValue) Type() StateVarType { - return state.baseType + return state.valueType } -func (state *StateValue) AddEventCondition(condition StateConditionFunc) { - state.eventConditions = append(state.eventConditions, condition) +func (state *StateValue) AddEventCondition(name string, condition StateConditionFunc) { + state.eventConditions[name] = condition +} + +func (state *StateValue) DeleteEventConditions(name string) error { + if _, ok := state.eventConditions[name]; !ok { + return fmt.Errorf("%s: no such event condition (%s)", state.name, name) + } + delete(state.eventConditions, name) + return nil } // ClearEventConditions réinitialise toutes les conditions func (state *StateValue) ClearEventConditions() { - state.eventConditions = nil + state.eventConditions = make(map[string]StateConditionFunc) +} + +func (sv *StateValue) SetMinDelta(minDelta interface{}) error { + if minDelta == nil { + return fmt.Errorf("%s: nil is an invalid minimum delta value", sv.name) + } + + minDelta, err := sv.Cast(minDelta) + if err != nil { + return fmt.Errorf("%s: invalid minimum delta value %v : %v", sv.name, minDelta,err) + } + + mdf := func(instance *StateValueInstance) bool { + o := instance.previousValue + n := instance.value + + + r,err := instance.model.valueType.ValueRange(o, n) + + if err != nil { + return false + } + + return instance.model.valueType.InRange() + } + + sv.eventConditions["MinDelta"] = mdf + return nil } func (state *StateValue) SetDefault(value interface{}) error { if state.IsValidValue(value) { - cvalue, _ := state.baseType.Cast(value) + cvalue, _ := state.valueType.Cast(value) state.defaultValue = cvalue log.Debugf("🐞 Setting default value for %v to %v", state.name, cvalue) return nil @@ -107,7 +107,7 @@ func (state *StateValue) HasDefault() bool { func (state *StateValue) DefaultValue() interface{} { if !state.HasDefault() { - return state.baseType.DefaultValue() + return state.valueType.DefaultValue() } return state.DefaultValue() @@ -156,7 +156,7 @@ func (state *StateValue) SetRange(min, max interface{}) error { if min == nil || max == nil { return fmt.Errorf("min and max must not be nil") } - limits, err := state.baseType.ValueRange(min, max) + limits, err := state.valueType.ValueRange(min, max) if err != nil { return fmt.Errorf("setting range: %v", err) } @@ -180,7 +180,7 @@ func (state *StateValue) UpdateMinimalValue(value interface{}) error { if state.valueRange == nil { return fmt.Errorf("no range set for value %v", state.name) } - cvalue, err := state.baseType.Cast(value) + cvalue, err := state.valueType.Cast(value) if err != nil { return fmt.Errorf("casting value: %v", err) } @@ -204,7 +204,7 @@ func (state *StateValue) UpdateMaximalValue(value interface{}) error { if state.valueRange == nil { return fmt.Errorf("no range set for value %v", state.name) } - cvalue, err := state.baseType.Cast(value) + cvalue, err := state.valueType.Cast(value) if err != nil { return fmt.Errorf("casting value: %v", err) } @@ -225,7 +225,7 @@ func (state *StateValue) UpdateMaximalValue(value interface{}) error { // // bool: True if within range or no range defined func (state *StateValue) IsValueInRange(value interface{}) bool { - return state.baseType.InRange(value, state.valueRange) + return state.valueType.InRange(value, state.valueRange) } // IsSendingEvents indicates if state changes trigger UPnP events. @@ -273,7 +273,7 @@ func (state *StateValue) AllowedValues() []interface{} { func (state *StateValue) AppendAllowedValue(value ...interface{}) error { state.allowedValues = slices.Grow(state.allowedValues, len(value)) for _, v := range value { - cv, err := state.baseType.Cast(v) + cv, err := state.valueType.Cast(v) if err != nil { return fmt.Errorf("casting allowed value: %v", err) } @@ -299,7 +299,7 @@ func (state *StateValue) IsValueAllowed(value interface{}) bool { return true // No list = any value valid } - cvalue, err := state.baseType.Cast(value) + cvalue, err := state.valueType.Cast(value) if err != nil { return false } @@ -322,7 +322,7 @@ func (state *StateValue) IsValueAllowed(value interface{}) bool { // // bool: True if value passes all applicable constraints func (state *StateValue) IsValidValue(value interface{}) bool { - cvalue, err := state.baseType.Cast(value) + cvalue, err := state.valueType.Cast(value) if err != nil { return false } @@ -355,7 +355,7 @@ func (state *StateValue) SetModifiable() { func (state *StateValue) SetStep(step interface{}) error { // Validation que le step correspond au type de la variable - if _, err := state.baseType.Cast(step); err != nil { + if _, err := state.valueType.Cast(step); err != nil { return fmt.Errorf("invalid step type: %v", err) } state.step = step @@ -382,3 +382,133 @@ func (state *StateValue) NewInstance() *StateValueInstance { lastEvent: time.Unix(int64(1718985600), 0).UTC(), } } + +// ToXMLElement generates the complete XML representation of the state variable +// Returns an etree.Element that can be serialized to XML +func (sv *StateValue) ToXMLElement() *etree.Element { + // Create root element + elem := etree.NewElement("stateVariable") + elem.CreateAttr("name", sv.name) + + // Add sendEvents attribute (UPnP eventing capability) + if sv.sendEvents { + elem.CreateAttr("sendEvents", "yes") // Enable event notifications + } else { + elem.CreateAttr("sendEvents", "no") // Disable event notifications + } + + // Add data type element + dataType := elem.CreateElement("dataType") + dataType.SetText(sv.valueType.String()) // Set UPnP type name (e.g., "ui1", "boolean") + + // Add default value if specified + if sv.defaultValue != nil { + defaultValue := elem.CreateElement("defaultValue") + // Convert value to UPnP-compatible string representation + defaultValue.SetText(sv.valueToString(sv.defaultValue)) + } + + // Add value range constraints if defined + if sv.valueRange != nil { + rangeElem := elem.CreateElement("allowedValueRange") + + // Minimum boundary value + min := rangeElem.CreateElement("minimum") + min.SetText(sv.valueToString(sv.valueRange.min)) + + // Maximum boundary value + max := rangeElem.CreateElement("maximum") + max.SetText(sv.valueToString(sv.valueRange.max)) + + // Add step value if defined (for incremental controls) + if sv.step != nil { + step := rangeElem.CreateElement("step") + step.SetText(sv.valueToString(sv.step)) + } + } + + // Add allowed value list if defined + if len(sv.allowedValues) > 0 { + allowedList := elem.CreateElement("allowedValueList") + for _, value := range sv.allowedValues { + // Create individual elements + allowed := allowedList.CreateElement("allowedValue") + allowed.SetText(sv.valueToString(value)) + } + } + + // Add description if available + if sv.description != "" { + desc := elem.CreateElement("description") + desc.SetText(sv.description) // Human-readable description + } + + return elem +} + +// valueToString converts a value to its UPnP-compatible string representation +// Handles type-specific formatting for proper XML serialization +func (sv *StateValue) valueToString(val interface{}) string { + if val == nil { + return "" // Safeguard against nil values + } + + // Type-specific formatting for UPnP compliance + switch sv.valueType { + case StateType_Boolean: + // Boolean: "1" for true, "0" for false (UPnP standard) + if b, ok := val.(bool); ok && b { + return "1" + } + return "0" + + case StateType_Date: + // Date: YYYY-MM-DD format + if t, ok := val.(time.Time); ok { + return t.Format("2006-01-02") + } + + case StateType_DateTime, StateType_DateTimeTZ: + // DateTime: ISO 8601 format with timezone + if t, ok := val.(time.Time); ok { + return t.Format(time.RFC3339) + } + + case StateType_Time, StateType_TimeTZ: + // Time: HH:MM:SS format + if t, ok := val.(time.Time); ok { + return t.Format("15:04:05") + } + + case StateType_BinBase64: + // Binary: Base64 encoding + if b, ok := val.([]byte); ok { + return base64.StdEncoding.EncodeToString(b) + } + + case StateType_BinHex: + // Binary: Hex encoding + if b, ok := val.([]byte); ok { + return hex.EncodeToString(b) + } + + case StateType_URI: + // URI: Full URL string + if u, ok := val.(*url.URL); ok { + return u.String() + } + + case StateType_UUID: + // UUID: Canonical string representation + if u, ok := val.(uuid.UUID); ok { + return u.String() + } + } + + // Default conversion for unsupported types or fallback + return fmt.Sprintf("%v", val) +} + +func (sv *StateValue) Cast(val interface{}) (interface{}, error) { + return sv.valueType.Cast(val) +} diff --git a/upnp/statevalueinstance.go b/upnp/statevalueinstance.go new file mode 100644 index 00000000..926767bd --- /dev/null +++ b/upnp/statevalueinstance.go @@ -0,0 +1,75 @@ +package upnp + +import ( + "sync" + "time" + + "github.com/beevik/etree" +) + +type StateValueInstance struct { + model *StateValue + value interface{} + previousValue interface{} + lastChange time.Time + lastEvent time.Time + mu sync.RWMutex +} + +func (instance *StateValueInstance) Cast(val interface{}) (interface{}, error) { + return instance.model.Cast(val) +} + +func (instance *StateValueInstance) Model() *StateValue { + return instance.model +} + +func (instance *StateValueInstance) Value() interface{} { + instance.mu.RLock() + defer instance.mu.RUnlock() + return instance.value +} + +func (instance *StateValueInstance) SetValue(val interface{}) error { + cval, err := instance.Cast(val) + + if err != nil { + return err + } + + instance.mu.Lock() + defer instance.mu.Unlock() + instance.previousValue = instance.value + instance.value = cval + return nil +} + +func (instance *StateValueInstance) Incr() { + instance.mu.Lock() + defer instance.mu.Unlock() + +} + +// ShouldTriggerEvent vérifie toutes les conditions +func (instance *StateValueInstance) ShouldTriggerEvent() bool { + for _, condition := range instance.model.eventConditions { + if !condition(instance) { + return false + } + } + return true +} + +func (sv *StateValueInstance) GenerateEvent() *etree.Element { + + // Construire le XML d'événement + propSet := etree.NewElement("e:propertyset") + propSet.CreateAttr("xmlns:e", "urn:schemas-upnp-org:event-1-0") + + prop := propSet.CreateElement("e:property") + elem := prop.CreateElement(sv.model.Name()) + elem.SetText(sv.model.valueToString(sv.Value())) + + return propSet + +} diff --git a/internal/upnp/statevaluetype.go b/upnp/statevaluetype.go similarity index 56% rename from internal/upnp/statevaluetype.go rename to upnp/statevaluetype.go index 7bde0733..78758b8b 100644 --- a/internal/upnp/statevaluetype.go +++ b/upnp/statevaluetype.go @@ -9,10 +9,8 @@ import ( "encoding/hex" "fmt" "log" - "math" "net/url" "reflect" - "strconv" "strings" "time" @@ -105,8 +103,8 @@ var typeStrings = [...]string{ "uri", } -// String returns the UPnP XML name of the type. -// Returns "unknown" for unrecognized types. +// String returns a string representation of the StateVarType. It defaults to +// "unknown" if the type is not recognized. func (t StateVarType) String() string { if int(t) >= 0 && int(t) < len(typeStrings) { return typeStrings[t] @@ -114,6 +112,122 @@ func (t StateVarType) String() string { return "unknown" } +// IsNumeric checks whether a given StateVarType represents a numeric type or +// not. Numeric types are defined as those that can be used to store number-like +// values. The following types are considered numeric: UI1, UI2, UI4, I1, I2, +// I4, Int, R4, R8, Number and Fixed14_4. +// +// t: StateVarType to check if it's a numeric type or not. +// +// Returns true if the given StateVarType represents a numeric type; false +// otherwise. +func (t StateVarType) IsNumeric() bool { + switch t { + case StateType_UI1, StateType_UI2, StateType_UI4, + StateType_I1, StateType_I2, StateType_I4, + StateType_Int, + StateType_R4, StateType_R8, + StateType_Number, + StateType_Fixed14_4: + return true + default: + return false + } +} + +// IsInteger checks if the state variable type is integer or not. +// +// It returns a boolean value indicating whether the provided StateVarType (t) +// is an integer type or not. The function takes one parameter, t of type +// StateVarType, which represents the state variable type to be checked. +// +// Parameters: - t (StateVarType): The StateVarType to check for comparability. +// +// Returns: bool: If the state variable type is any of the defined integer types +// (StateType_UI1, StateType_UI2, StateType_UI4, StateType_I1, StateType_I2, +// StateType_I4, StateType_Int), it returns true. Otherwise, it returns false. +func (t StateVarType) IsInteger() bool { + switch t { + case StateType_UI1, StateType_UI2, StateType_UI4, + StateType_I1, StateType_I2, StateType_I4, + StateType_Int: + return true + default: + return false + } +} + +// IsComparable function checks if a StateVarType is comparable or not. +// +// It returns false for binary types (StateType_BinBase64 and StateType_BinHex) +// as they are non-comparable. For all other types, it returns true indicating +// that these types can be compared. +// +// Parameters: - t (StateVarType): The StateVarType to check for comparability. +// +// Returns: bool: A boolean value indicating whether the given StateVarType is +// comparable or not. True means it's comparable, False means it isn't. +func (t StateVarType) IsComparable() bool { + // Tous les types sauf les binaires sont comparables + switch t { + case StateType_BinBase64, StateType_BinHex: + return false + default: + return true + } +} + +// Add performs addition operation on two interfaces if both are of numeric +// type, otherwise it returns an error. If the types are not numeric, it checks +// and converts them into float64 before performing the addition. The function +// then casts the result back to its original type using Cast method from +// StateVarType t and returns this value or any encountered error. +// +// Parameters: +// +// a (interface{}): First operand for addition operation. Can be of any type. +// b (interface{}): Second operand for addition operation. Can be of any type. +// +// Returns: +// +// interface{}: Result of the addition, casted back to its original type using StateVarType t if no error encountered. +// error: Encountered error in case any conversion or casting fails. This includes non-numeric types for this operation. +func (t StateVarType) Add(a, b interface{}) (interface{}, error) { + af, bf, err := valuesToNumericOperands(t, a, b) + if err != nil { + return nil, err + } + + return t.Cast(af + bf) +} + +func (t StateVarType) Sub(a, b interface{}) (interface{}, error) { + af, bf, err := valuesToNumericOperands(t, a, b) + if err != nil { + return nil, err + } + + return t.Cast(af - bf) +} + +func (t StateVarType) Mul(a, b interface{}) (interface{}, error) { + af, bf, err := valuesToNumericOperands(t, a, b) + if err != nil { + return nil, err + } + + return t.Cast(af * bf) +} + +func (t StateVarType) Div(a, b interface{}) (interface{}, error) { + af, bf, err := valuesToNumericOperands(t, a, b) + if err != nil { + return nil, err + } + + return t.Cast(af / bf) +} + // ParseStateVarType converts a UPnP type name to its StateVarType constant. // Case-insensitive and trims whitespace. Returns StateType_Unknown for unrecognized types. func ParseStateVarType(s string) StateVarType { @@ -135,64 +249,64 @@ func ParseStateVarType(s string) StateVarType { func (t StateVarType) Cast(val interface{}) (interface{}, error) { switch t { case StateType_UI1: - v, ok := toUint(val, 8) - if !ok { + v, err := toUint(val, 8) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to UI1", val, val) } return uint8(v), nil case StateType_UI2: - v, ok := toUint(val, 16) - if !ok { + v, err := toUint(val, 16) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to UI2", val, val) } return uint16(v), nil case StateType_UI4: - v, ok := toUint(val, 32) - if !ok { + v, err := toUint(val, 32) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to UI4", val, val) } return uint32(v), nil case StateType_I1: - v, ok := toInt(val, 8) - if !ok { + v, err := toInt(val, 8) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to I1", val, val) } return int8(v), nil case StateType_I2: - v, ok := toInt(val, 16) - if !ok { + v, err := toInt(val, 16) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to I2", val, val) } return int16(v), nil case StateType_I4, StateType_Int: - v, ok := toInt(val, 32) - if !ok { + v, err := toInt(val, 32) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to I4", val, val) } return int32(v), nil case StateType_R4: - v, ok := toFloat(val, 32) - if !ok { + v, err := toFloat(val, 32) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to R4", val, val) } return float32(v), nil case StateType_R8, StateType_Number, StateType_Fixed14_4: - v, ok := toFloat(val, 64) - if !ok { + v, err := toFloat(val, 64) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to R8", val, val) } return v, nil case StateType_Boolean: - b, ok := toBool(val) - if !ok { + b, err := toBool(val) + if err != nil { return nil, fmt.Errorf("cannot cast %v (%T) to Boolean", val, val) } return b, nil @@ -383,87 +497,12 @@ func (t StateVarType) InRange(val interface{}, interval *ValueRange) bool { return interval == nil || t.Cmp(val, interval.min) >= 0 && t.Cmp(val, interval.max) <= 0 } -// ValueRange creates a valid value range for the UPnP type. -// -// This method casts the provided min and max values to the UPnP type and returns -// a ValueRange struct suitable for range validation. If either value cannot be -// cast to the type, it returns an error. -// -// Parameters: -// -// min: Minimum value of the range (inclusive) -// max: Maximum value of the range (inclusive) -// -// Returns: -// -// ValueRange: Valid range structure if cast succeeds -// error: If min or max cannot be cast to the type -// -// Example: -// -// // Create a range for UI2 (uint16) values -// r, err := StateType_UI2.ValueRange(10, 100) -// if err != nil { /* handle error */ } -// valid := StateType_UI2.InRange(50, r) // true -// -// Notes: -// - The range is inclusive: [min, max] -// - Values must be comparable using the type's comparison logic -// - For time types, min and max must be valid time values -func (t StateVarType) ValueRange(min, max interface{}) (*ValueRange, error) { - cmin, error := t.Cast(min) - if error != nil { - return nil, fmt.Errorf("min value %v is not castable to type %s", min, t.String()) - } - cmax, error := t.Cast(max) - if error != nil { - return nil, fmt.Errorf("max value %v is not castable to type %s", min, t.String()) - } - - return &ValueRange{min: cmin, max: cmax}, nil -} - // NewAtomicValue crée une valeur simple func (t StateVarType) NewAtomicValue(name string) *StateValue { return &StateValue{ - name: name, - baseType: t, - modifier: ModifierAtomic, - } -} - -// NewListValue crée une liste -func (t StateVarType) NewListValue(name string, elementType StateVarType) *StateValue { - return &StateValue{ - name: name, - baseType: t, - modifier: ModifierList, - elementType: elementType, - } -} - -// NewMapValue crée une map -func (t StateVarType) NewMapValue( - name string, - keyType StateVarType, - valueType StateVarType, -) *StateValue { - return &StateValue{ - name: name, - baseType: t, - modifier: ModifierMap, - keyType: keyType, - elementType: valueType, // elementType = valeur de la map - } -} - -// NewStructValue crée une valeur de type struct -func (t StateVarType) NewStructValue(name string, fields map[string]StateVarType) *StateValue { - return &StateValue{ - name: name, - baseType: t, - modifier: ModifierStruct, - structFields: fields, + name: name, + valueType: t, + eventConditions: make(map[string]StateConditionFunc), } } @@ -509,376 +548,6 @@ func (t StateVarType) DefaultValue() interface{} { return nil } -// toInt converts various types to signed integer with specified bit size. -// Handles overflow/underflow. Returns converted value and success status. -func toInt(v interface{}, bits int) (int64, bool) { - min := minInt(bits) - max := maxInt(bits) - - switch val := v.(type) { - case int: - if int64(val) < min || int64(val) > max { - return 0, false - } - return int64(val), true - case int8: - if int64(val) < min || int64(val) > max { - return 0, false - } - return int64(val), true - case int16: - if int64(val) < min || int64(val) > max { - return 0, false - } - return int64(val), true - case int32: - if int64(val) < min || int64(val) > max { - return 0, false - } - return int64(val), true - case int64: - if val < min || val > max { - return 0, false - } - return val, true - case uint: - if uint64(val) > uint64(max) { - return 0, false - } - return int64(val), true - case uint8: - if uint64(val) > uint64(max) { - return 0, false - } - return int64(val), true - case uint16: - if uint64(val) > uint64(max) { - return 0, false - } - return int64(val), true - case uint32: - if uint64(val) > uint64(max) { - return 0, false - } - return int64(val), true - case uint64: - if val > uint64(max) { - return 0, false - } - return int64(val), true - case float32: - r := int64(math.Round(float64(val))) - if r < min || r > max { - return 0, false - } - return r, true - case float64: - r := int64(math.Round(val)) - if r < min || r > max { - return 0, false - } - return r, true - case string: - // Try int parse direct - if i, err := strconv.ParseInt(val, 10, bits); err == nil { - if i < min || i > max { - return 0, false - } - return i, true - } - // Try float parse then round + bounds check - if f, err := strconv.ParseFloat(val, 64); err == nil { - r := int64(math.Round(f)) - if r < min || r > max { - return 0, false - } - return r, true - } - return 0, false - default: - return 0, false - } -} - -// toUint converts various types to unsigned integer with specified bit size. -// Handles numeric types and strings. Returns converted value and success status. -func toUint(v interface{}, bits int) (uint64, bool) { - max := maxUint(bits) - - switch val := v.(type) { - case uint: - if uint64(val) > max { - return 0, false - } - return uint64(val), true - case uint8: - if uint64(val) > max { - return 0, false - } - return uint64(val), true - case uint16: - if uint64(val) > max { - return 0, false - } - return uint64(val), true - case uint32: - if uint64(val) > max { - return 0, false - } - return uint64(val), true - case uint64: - if val > max { - return 0, false - } - return val, true - - case int: - if val < 0 || uint64(val) > max { - return 0, false - } - return uint64(val), true - case int8: - if val < 0 || uint64(val) > max { - return 0, false - } - return uint64(val), true - case int16: - if val < 0 || uint64(val) > max { - return 0, false - } - return uint64(val), true - case int32: - if val < 0 || uint64(val) > max { - return 0, false - } - return uint64(val), true - case int64: - if val < 0 || uint64(val) > max { - return 0, false - } - return uint64(val), true - - case float32: - r := uint64(math.Round(float64(val))) - if r > max { - return 0, false - } - return r, true - case float64: - r := uint64(math.Round(val)) - if r > max { - return 0, false - } - return r, true - - case string: - // Try parse float first (handles int and float strings) - f, err := strconv.ParseFloat(val, 64) - if err != nil || f < 0 { - return 0, false - } - r := uint64(math.Round(f)) - if r > max { - return 0, false - } - return r, true - - default: - return 0, false - } -} - -// maxUint returns maximum unsigned integer value for specified bit size -func maxUint(bits int) uint64 { - switch bits { - case 8: - return math.MaxUint8 - case 16: - return math.MaxUint16 - case 32: - return math.MaxUint32 - case 64: - return math.MaxUint64 - default: - return math.MaxUint64 // fallback - } -} - -// minInt returns minimum signed integer value for specified bit size -func minInt(bits int) int64 { - switch bits { - case 8: - return math.MinInt8 - case 16: - return math.MinInt16 - case 32: - return math.MinInt32 - case 64: - return math.MinInt64 - default: - return math.MinInt64 // fallback - } -} - -// maxInt returns maximum signed integer value for specified bit size -func maxInt(bits int) int64 { - switch bits { - case 8: - return math.MaxInt8 - case 16: - return math.MaxInt16 - case 32: - return math.MaxInt32 - case 64: - return math.MaxInt64 - default: - return math.MaxInt64 // fallback - } -} - -// toFloat converts various types to float (32 or 64 bits). -// Checks float32 boundaries when converting to 32-bit float. -func toFloat(v interface{}, bits int) (float64, bool) { - switch val := v.(type) { - case float32: - f := float64(val) - if bits == 32 && (f > math.MaxFloat32 || f < -math.MaxFloat32) { - return 0, false - } - return f, true - case float64: - if bits == 32 && (val > math.MaxFloat32 || val < -math.MaxFloat32) { - return 0, false - } - return val, true - case int, int8, int16, int32, int64: - f := float64(reflect.ValueOf(val).Int()) - if bits == 32 && (f > math.MaxFloat32 || f < -math.MaxFloat32) { - return 0, false - } - return f, true - case uint, uint8, uint16, uint32, uint64: - f := float64(reflect.ValueOf(val).Uint()) - if bits == 32 && (f > math.MaxFloat32 || f < -math.MaxFloat32) { - return 0, false - } - return f, true - case string: - f, err := strconv.ParseFloat(val, bits) - if err != nil { - return 0, false - } - if bits == 32 && (f > math.MaxFloat32 || f < -math.MaxFloat32) { - return 0, false - } - return f, true - default: - return 0, false - } -} - -// toBool converts various types to boolean following UPnP rules: -// true: 1, "true"; false: 0, "false" -func toBool(val interface{}) (bool, bool) { - switch v := val.(type) { - case bool: - return v, true - - case int: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case int8: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case int16: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case int32: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case int64: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - - case uint: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case uint8: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case uint16: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case uint32: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - case uint64: - if v == 0 { - return false, true - } else if v == 1 { - return true, true - } - - case float32: - if v == 0.0 { - return false, true - } else if v == 1.0 { - return true, true - } - case float64: - if v == 0.0 { - return false, true - } else if v == 1.0 { - return true, true - } - - case string: - return parseUPnPBoolean(v) - } - - return false, false -} - -// parseUPnPBoolean parses boolean from string: -// "1"/"true" → true, "0"/"false" → false -func parseUPnPBoolean(s string) (bool, bool) { - switch strings.TrimSpace(strings.ToLower(s)) { - case "1", "true": - return true, true - case "0", "false": - return false, true - default: - return false, false - } -} - // decodeBinary decodes Base64 or Hex-encoded binary strings to byte slices func decodeBinary(t StateVarType, val string) ([]byte, error) { switch t { diff --git a/upnp/utils_bool.go b/upnp/utils_bool.go new file mode 100644 index 00000000..1f2b8a9a --- /dev/null +++ b/upnp/utils_bool.go @@ -0,0 +1,49 @@ +package upnp + +import ( + "errors" + "strings" +) + +// parseUPnPBoolean parses a string like "true", "false", "1", "0" into a boolean. +func parseUPnPBoolean(s string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true": + return true, nil + case "0", "false": + return false, nil + default: + return false, errors.New("invalid string for UPnP boolean") + } +} + +// toBool converts various types to boolean following UPnP rules: +// true: 1, "true"; false: 0, "false" +func toBool(val interface{}) (bool, error) { + if val == nil { + return false, errors.New("cannot convert nil to bool") + } + + switch v := val.(type) { + case bool: + return v, nil + + case string: + return parseUPnPBoolean(v) + + default: + // try to convert numerics to float + f, err := toFloat(v, 64) + if err != nil { + return false, err + } + + if f == 1.0 { + return true, nil + } + if f == 0.0 { + return false, nil + } + return false, errors.New("numeric value cannot be converted to bool unless 0 or 1") + } +} diff --git a/upnp/utils_float.go b/upnp/utils_float.go new file mode 100644 index 00000000..c3b0d0a8 --- /dev/null +++ b/upnp/utils_float.go @@ -0,0 +1,77 @@ +package upnp + +import ( + "fmt" + "math" + "strconv" +) + +func maxFloat(bits int) float64 { + switch bits { + case 32: + return float64(math.MaxFloat32) + case 64: + return math.MaxFloat64 + default: + return math.MaxFloat64 // fallback + } +} + +func minFloat(bits int) float64 { + switch bits { + case 32: + return -float64(math.MaxFloat32) + case 64: + return -math.MaxFloat64 + default: + return -math.MaxFloat64 // fallback + } +} + +// toFloat converts various types to float (32 or 64 bits). +// Checks float32 boundaries when converting to 32-bit float. +// toFloat converts v to a float64, ensuring it fits within the range of the requested float size. +func toFloat(v interface{}, bits int) (float64, error) { + var f float64 + + switch val := v.(type) { + case float32: + f = float64(val) + case float64: + f = val + case int: + f = float64(val) + case int8: + f = float64(val) + case int16: + f = float64(val) + case int32: + f = float64(val) + case int64: + f = float64(val) + case uint: + f = float64(val) + case uint8: + f = float64(val) + case uint16: + f = float64(val) + case uint32: + f = float64(val) + case uint64: + f = float64(val) + case string: + var err error + f, err = strconv.ParseFloat(val, bits) + if err != nil { + return 0, err + } + default: + return 0, fmt.Errorf("%T,unsupported type in toFloat", v) + } + + if f < minFloat(bits) || f > maxFloat(bits) { + return 0, fmt.Errorf("value %v overflows float%d range", v, bits) + } + + return f, nil +} diff --git a/upnp/utils_int.go b/upnp/utils_int.go new file mode 100644 index 00000000..5e81b052 --- /dev/null +++ b/upnp/utils_int.go @@ -0,0 +1,116 @@ +package upnp + +import ( + "errors" + "math" + "reflect" + "strconv" +) + +// minInt returns minimum signed integer value for specified bit size +func minInt(bits int) int64 { + switch bits { + case 8: + return math.MinInt8 + case 16: + return math.MinInt16 + case 32: + return math.MinInt32 + case 64: + return math.MinInt64 + default: + return math.MinInt64 // fallback + } +} + +// maxInt returns maximum signed integer value for specified bit size +func maxInt(bits int) int64 { + switch bits { + case 8: + return math.MaxInt8 + case 16: + return math.MaxInt16 + case 32: + return math.MaxInt32 + case 64: + return math.MaxInt64 + default: + return math.MaxInt64 // fallback + } +} + +// toInt converts the given interface value into an int64. The function accepts +// a parameter v of any type and an integer bits representing the size of the +// desired integer type (8, 16, 32 or 64). If successful, it returns the +// converted integer and nil for error. Otherwise, it returns zero for int64 and +// an appropriate error message. +// +// The function checks whether v is nil. If true, it returns an error stating +// "cannot convert nil to int". +// +// It then identifies the type of v using a type switch statement. Depending on +// the type, the function performs different actions: +// - For integer types (int, int8, int16, int32 and int64), it uses checkIntBounds() to ensure the value fits within the specified bits range and returns the result. +// - For unsigned types (uint, uint8, uint16, uint32 and uint64), it checks for overflow before converting to an int64 and calls checkIntBounds(). +// - For float types (float32 and float64), it converts them to int64 directly. +// - For string type, it attempts to parse the string as a base-10 integer using strconv.ParseInt() with specified bits range. +// +// If no match is found in these cases or v is of unsupported type, it returns +// an error stating "unsupported type for toInt". +func toInt(v interface{}, bits int) (int64, error) { + if v == nil { + return 0, errors.New("cannot convert nil to int") + } + + switch val := v.(type) { + case int: + return checkIntBounds(int64(val), bits) + case int8: + return checkIntBounds(int64(val), bits) + case int16: + return checkIntBounds(int64(val), bits) + case int32: + return checkIntBounds(int64(val), bits) + case int64: + return checkIntBounds(val, bits) + + case uint, uint8, uint16, uint32, uint64: + u := reflect.ValueOf(val).Uint() + if u > uint64(math.MaxInt64) { + return 0, errors.New("unsigned value overflows int64") + } + return checkIntBounds(int64(u), bits) + + case float32: + return checkIntBounds(int64(val), bits) + case float64: + return checkIntBounds(int64(val), bits) + + case string: + i, err := strconv.ParseInt(val, 10, bits) + if err != nil { + return 0, err + } + return checkIntBounds(i, bits) + + default: + return 0, errors.New("unsupported type for toInt") + } +} + +// CheckIntBounds checks if a given int64 value is within the valid range for a specific number of bits. +// It returns an error and 0 if the value is out of bounds, else it returns the input value and nil. +// +// Parameters: +// - v: The integer value to check. +// - bits: The number of bits that determine the valid range for 'v'. +// +// Returns: +// - int64: The original input value if within bounds, else 0. +// - error: An error object if the value is out of bounds; nil otherwise. +func checkIntBounds(v int64, bits int) (int64, error) { + if v < minInt(bits) || v > maxInt(bits) { + return 0, errors.New("integer value out of bounds") + } + return v, nil +} diff --git a/upnp/utils_numeric_operandes.go b/upnp/utils_numeric_operandes.go new file mode 100644 index 00000000..52b749be --- /dev/null +++ b/upnp/utils_numeric_operandes.go @@ -0,0 +1,30 @@ +package upnp + +import "fmt" + +func valuesToNumericOperands(t StateVarType, a interface{}, b interface{}) (float64, float64, error) { + var err error + if !t.IsNumeric() { + return 0, 0, fmt.Errorf("type %v is not numeric", t) + } + + a, err = t.Cast(a) + if err != nil { + return 0, 0, err + } + + b, err = t.Cast(b) + if err != nil { + return 0, 0, err + } + + af, err := toFloat(a, 64) + if err != nil { + return 0, 0, err + } + bf, err := toFloat(b, 64) + if err != nil { + return 0, 0, err + } + return af, bf, nil +} diff --git a/upnp/utils_uint.go b/upnp/utils_uint.go new file mode 100644 index 00000000..b45095b6 --- /dev/null +++ b/upnp/utils_uint.go @@ -0,0 +1,100 @@ +package upnp + +import ( + "errors" + "math" + "reflect" + "strconv" +) + +// maxUint returns maximum unsigned integer value for specified bit size +func maxUint(bits int) uint64 { + switch bits { + case 8: + return math.MaxUint8 + case 16: + return math.MaxUint16 + case 32: + return math.MaxUint32 + case 64: + return math.MaxUint64 + default: + return math.MaxUint64 // fallback + } +} + +// toUint converts various types of numeric values into a uint64 type. It +// supports conversions from signed and unsigned integers, floating-point +// numbers, and string representations of integers. +// +// Parameters: +// - v interface{}: input value that can be converted to uint64. The +// function will return an error if the input is not one of these types. +// - bits int: number of bits that should fit within the returned uint64. +// An error will be returned if the conversion would exceed this limit. +// +// Returns: +// - uint64: converted value from input 'v'. If the input 'v' is a string, +// it must represent an integer in base 10 and can fit into an uint64 +// type with given number of bits. +// - error: if the conversion fails due to unsupported input type, overflow +// or underflow conditions, or invalid string representation, this will +// contain an appropriate error message. If 'v' is nil, +// it returns an "cannot convert nil to uint" error. +func toUint(v interface{}, bits int) (uint64, error) { + if v == nil { + return 0, errors.New("cannot convert nil to uint") + } + + switch val := v.(type) { + case uint: + return checkUintBounds(uint64(val), bits) + case uint8: + return checkUintBounds(uint64(val), bits) + case uint16: + return checkUintBounds(uint64(val), bits) + case uint32: + return checkUintBounds(uint64(val), bits) + case uint64: + return checkUintBounds(val, bits) + + case int, int8, int16, int32, int64: + i := reflect.ValueOf(val).Int() + if i < 0 { + return 0, errors.New("negative value cannot be converted to uint") + } + return checkUintBounds(uint64(i), bits) + + case float32: + if val < 0 { + return 0, errors.New("negative float cannot be converted to uint") + } + return checkUintBounds(uint64(val), bits) + case float64: + if val < 0 { + return 0, errors.New("negative float cannot be converted to uint") + } + return checkUintBounds(uint64(val), bits) + + case string: + u, err := strconv.ParseUint(val, 10, bits) + if err != nil { + return 0, err + } + return checkUintBounds(u, bits) + + default: + return 0, errors.New("unsupported type for toUint") + } +} + +// checkUintBounds checks whether the given unsigned integer 'v' is within the +// acceptable range defined by the number of bits 'bits'. If 'v' is out of +// bounds, it returns an error with a message "unsigned integer value out of +// bounds". Otherwise, it returns 'v' and nil. +func checkUintBounds(v uint64, bits int) (uint64, error) { + if v > maxUint(bits) { + return 0, errors.New("unsigned integer value out of bounds") + } + return v, nil +} diff --git a/upnp/valuerange.go b/upnp/valuerange.go new file mode 100644 index 00000000..6f696f3f --- /dev/null +++ b/upnp/valuerange.go @@ -0,0 +1,61 @@ +package upnp + +import "fmt" + +// ValueRange represents an inclusive range constraint for a state variable value. +// It defines the minimum and maximum allowable values for a given UPnP type. +// +// Usage: +// - For numeric types: min/max must be numeric types +// - For time types: min/max must be time.Time values +// - For strings/UUIDs: min/max must be string values +// +// Use with StateVarType.InRange() to check if values fall within the range. +type ValueRange struct { + min interface{} + max interface{} +} + +// ValueRange creates a valid value range for the UPnP type. +// +// This method casts the provided min and max values to the UPnP type and returns +// a ValueRange struct suitable for range validation. If either value cannot be +// cast to the type, it returns an error. +// +// Parameters: +// +// min: Minimum value of the range (inclusive) +// max: Maximum value of the range (inclusive) +// +// Returns: +// +// ValueRange: Valid range structure if cast succeeds +// error: If min or max cannot be cast to the type +// +// Example: +// +// // Create a range for UI2 (uint16) values +// r, err := StateType_UI2.ValueRange(10, 100) +// if err != nil { /* handle error */ } +// valid := StateType_UI2.InRange(50, r) // true +// +// Notes: +// - The range is inclusive: [min, max] +// - Values must be comparable using the type's comparison logic +// - For time types, min and max must be valid time values +func (t StateVarType) ValueRange(min, max interface{}) (*ValueRange, error) { + cmin, error := t.Cast(min) + if error != nil { + return nil, fmt.Errorf("min value %v is not castable to type %s", min, t.String()) + } + cmax, error := t.Cast(max) + if error != nil { + return nil, fmt.Errorf("max value %v is not castable to type %s", min, t.String()) + } + + if t.Cmp(cmin, cmax) > 0 { + cmax, cmin = cmin, cmax + } + + return &ValueRange{min: cmin, max: cmax}, nil +}