change a lot

This commit is contained in:
2025-07-12 12:28:13 +02:00
parent a22eaa641a
commit 821a37f975
32 changed files with 880 additions and 1794 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
/vendor/
**/*.log
**/*.old
xxx

View File

@@ -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 lIP 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)
}
}

View File

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

View File

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

View File

@@ -1 +0,0 @@
package renderer

View File

@@ -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 `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:` + action + `Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
</u:` + action + `Response>
</s:Body>
</s:Envelope>`
}

View File

@@ -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 ""
}

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
package upnp
import (
"fmt"
)
func generateDeviceDescription(device *DeviceDescription) string {
return fmt.Sprintf(`<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>http://%s:%d/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>pmomusic Fake Renderer</friendlyName>
<manufacturer>GoDLNA</manufacturer>
<manufacturerURL>http://example.com</manufacturerURL>
<modelDescription>Fake DLNA Renderer</modelDescription>
<modelName>pmomusic</modelName>
<modelNumber>1.0</modelNumber>
<modelURL>http://example.com/model</modelURL>
<UDN>%s</UDN>
<presentationURL>http://%s:%d</presentationURL>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
<controlURL>/upnp/control/AVTransport</controlURL>
<eventSubURL>/upnp/event/AVTransport</eventSubURL>
<SCPDURL>/scpd/AVTransport.xml</SCPDURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
<controlURL>/upnp/control/RenderingControl</controlURL>
<eventSubURL>/upnp/event/RenderingControl</eventSubURL>
<SCPDURL>/scpd/RenderingControl.xml</SCPDURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<controlURL>/upnp/control/ConnectionManager</controlURL>
<eventSubURL>/upnp/event/ConnectionManager</eventSubURL>
<SCPDURL>/scpd/ConnectionManager.xml</SCPDURL>
</service>
<service>
<serviceType>urn:av-openhome-org:service:Product:1</serviceType>
<serviceId>urn:av-openhome-org:serviceId:Product</serviceId>
<controlURL>/upnp/control/Product</controlURL>
<eventSubURL>/upnp/event/Product</eventSubURL>
<SCPDURL>/scpd/Product.xml</SCPDURL>
</service>
<service>
<serviceType>urn:av-openhome-org:service:Playlist:1</serviceType>
<serviceId>urn:av-openhome-org:serviceId:Playlist</serviceId>
<controlURL>/upnp/control/Playlist</controlURL>
<eventSubURL>/upnp/event/Playlist</eventSubURL>
<SCPDURL>/scpd/Playlist.xml</SCPDURL>
</service>
<service>
<serviceType>urn:av-openhome-org:service:Info:1</serviceType>
<serviceId>urn:av-openhome-org:serviceId:Info</serviceId>
<controlURL>/upnp/control/Info</controlURL>
<eventSubURL>/upnp/event/Info</eventSubURL>
<SCPDURL>/scpd/Info.xml</SCPDURL>
</service>
</serviceList>
</device>
</root>`, device.IP, device.Port, device.USN, device.IP, device.Port)
}

View File

@@ -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 = `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>http://{{.IP}}:{{.Port}}/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>{{.Friendly}}</friendlyName>
<manufacturer>GoDLNA</manufacturer>
<manufacturerURL>http://example.com</manufacturerURL>
<modelDescription>Fake DLNA Renderer</modelDescription>
<modelName>{{.ModelName}}</modelName>
<modelNumber>1.0</modelNumber>
<modelURL>http://example.com/model</modelURL>
<UDN>{{.USN}}</UDN>
<presentationURL>http://{{.IP}}:{{.Port}}/</presentationURL>
{{.ServicesXML}}
</device>
</root>`
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
}

View File

@@ -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))
}

View File

@@ -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 = `
<service>
<serviceType>{{.Type}}</serviceType>
<serviceId>{{.ID}}</serviceId>
<controlURL>{{.URLPrefix}}/control</controlURL>
<eventSubURL>{{.URLPrefix}}/event</eventSubURL>
<SCPDURL>{{.URLPrefix}}.xml</SCPDURL>
</service>
`
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 = `
<serviceList>
{{range .Services}}{{.XML}}
{{end}}</serviceList>`
// 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
}

View File

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

View File

@@ -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{}
}

View File

@@ -1,81 +0,0 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>SetAVTransportURI</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
<argument>
<name>CurrentURI</name>
<direction>in</direction>
<relatedStateVariable>AVTransportURI</relatedStateVariable>
</argument>
<argument>
<name>CurrentURIMetaData</name>
<direction>in</direction>
<relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Play</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
<argument>
<name>Speed</name>
<direction>in</direction>
<relatedStateVariable>TransportPlaySpeed</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Stop</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_InstanceID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>AVTransportURI</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>AVTransportURIMetaData</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TransportPlaySpeed</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>1</allowedValue>
</allowedValueList>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -1,31 +0,0 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion><major>1</major><minor>0</minor></specVersion>
<actionList>
<action>
<name>GetProtocolInfo</name>
<argumentList>
<argument>
<name>Source</name>
<direction>out</direction>
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>Sink</name>
<direction>out</direction>
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>SourceProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SinkProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -1,111 +0,0 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Track</name>
<argumentList>
<argument>
<name>Uri</name>
<direction>out</direction>
<relatedStateVariable>TrackUri</relatedStateVariable>
</argument>
<argument>
<name>Metadata</name>
<direction>out</direction>
<relatedStateVariable>TrackMetadata</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Details</name>
<argumentList>
<argument>
<name>Duration</name>
<direction>out</direction>
<relatedStateVariable>TrackDuration</relatedStateVariable>
</argument>
<argument>
<name>BitRate</name>
<direction>out</direction>
<relatedStateVariable>TrackBitRate</relatedStateVariable>
</argument>
<argument>
<name>BitDepth</name>
<direction>out</direction>
<relatedStateVariable>TrackBitDepth</relatedStateVariable>
</argument>
<argument>
<name>SampleRate</name>
<direction>out</direction>
<relatedStateVariable>TrackSampleRate</relatedStateVariable>
</argument>
<argument>
<name>Lossless</name>
<direction>out</direction>
<relatedStateVariable>TrackLossless</relatedStateVariable>
</argument>
<argument>
<name>CodecName</name>
<direction>out</direction>
<relatedStateVariable>TrackCodecName</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Metatext</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Metatext</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>TrackUri</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackMetadata</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackDuration</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackBitRate</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackBitDepth</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackSampleRate</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackLossless</name>
<dataType>boolean</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackCodecName</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>Metatext</name>
<dataType>string</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -1,89 +0,0 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Play</name>
<argumentList>
<argument>
<name>Value</name>
<direction>in</direction>
<relatedStateVariable>TransportState</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Pause</name>
</action>
<action>
<name>Stop</name>
</action>
<action>
<name>Next</name>
</action>
<action>
<name>Previous</name>
</action>
<action>
<name>Insert</name>
<argumentList>
<argument>
<name>AfterId</name>
<direction>in</direction>
<relatedStateVariable>TrackId</relatedStateVariable>
</argument>
<argument>
<name>Uri</name>
<direction>in</direction>
<relatedStateVariable>Uri</relatedStateVariable>
</argument>
<argument>
<name>Metadata</name>
<direction>in</direction>
<relatedStateVariable>Metadata</relatedStateVariable>
</argument>
<argument>
<name>NewId</name>
<direction>out</direction>
<relatedStateVariable>TrackId</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>TransportState</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Playing</allowedValue>
<allowedValue>Paused</allowedValue>
<allowedValue>Stopped</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>Uri</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Metadata</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TrackId</name>
<dataType>ui4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -1,86 +0,0 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Manufacturer</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Manufacturer</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Model</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Model</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Product</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Product</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Standby</name>
<argumentList>
<argument>
<name>Value</name>
<direction>in</direction>
<relatedStateVariable>Standby</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>SetStandby</name>
<argumentList>
<argument>
<name>Value</name>
<direction>in</direction>
<relatedStateVariable>Standby</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>Manufacturer</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Model</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Product</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>Standby</name>
<dataType>boolean</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -1,43 +0,0 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion><major>1</major><minor>0</minor></specVersion>
<actionList>
<action>
<name>SetVolume</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
<argument>
<name>Channel</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
</argument>
<argument>
<name>DesiredVolume</name>
<direction>in</direction>
<relatedStateVariable>Volume</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_InstanceID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Channel</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Master</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>Volume</name>
<dataType>ui2</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

5
upnp/events.go Normal file
View File

@@ -0,0 +1,5 @@
package upnp
import "github.com/beevik/etree"
type UpnpEvent *etree.Element

View File

@@ -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 <stateVariable> 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 <allowedValue> 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)
}

View File

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

View File

@@ -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 {

49
upnp/utils_bool.go Normal file
View File

@@ -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")
}
}

77
upnp/utils_float.go Normal file
View File

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

116
upnp/utils_int.go Normal file
View File

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

View File

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

100
upnp/utils_uint.go Normal file
View File

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

61
upnp/valuerange.go Normal file
View File

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