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
+}