On renome le module upnp pmoupnp pour homogénéiser le tout

This commit is contained in:
2025-09-09 19:42:32 +02:00
parent 85e053aad0
commit b8370a552a
87 changed files with 3 additions and 3 deletions

35
pmoupnp/debug_index.go Normal file
View File

@@ -0,0 +1,35 @@
package upnp
import (
"fmt"
"html"
"net/http"
)
func (s *Server) ServeDebugIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>UPnP Debug Interface</title>
<style>
body { font-family: sans-serif; margin: 2em; }
h1 { border-bottom: 1px solid #ccc; }
pre { background: #f5f5f5; padding: 1em; overflow-x: auto; }
a { color: #007bff; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Host %s </h1>
<h2>address: %s</h2>`,
s.Name(),
html.EscapeString(html.EscapeString(s.BaseURL())))
fmt.Fprint(w, `
</body>
</html>
`)
}

36
pmoupnp/dev_set.go Normal file
View File

@@ -0,0 +1,36 @@
package upnp
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
"github.com/beevik/etree"
)
type DeviceInstanceSet objectstore.ObjectSet[*DeviceInstance]
func (m *DeviceInstanceSet) Insert(obj *DeviceInstance) error {
return (*objectstore.ObjectSet[*DeviceInstance])(m).Insert(obj)
}
func (m *DeviceInstanceSet) InsertOrReplace(obj *DeviceInstance) {
(*objectstore.ObjectSet[*DeviceInstance])(m).InsertOrReplace(obj)
}
func (set *DeviceInstanceSet) Contains(obj *DeviceInstance) bool {
return (*objectstore.ObjectSet[*DeviceInstance])(set).Contains(obj)
}
func (m *DeviceInstanceSet) All() iter.Seq[*DeviceInstance] {
return (*objectstore.ObjectSet[*DeviceInstance])(m).All()
}
func (m *DeviceInstanceSet) ToXMLElement() *etree.Element {
elem := etree.NewElement("DeviceList")
for sv := range m.All() {
elem.AddChild(sv.ToXMLElement())
}
return elem
}

160
pmoupnp/device.go Normal file
View File

@@ -0,0 +1,160 @@
package upnp
import (
"fmt"
)
type DeviceType string
const (
MediaServer DeviceType = "MediaServer"
MediaRenderer DeviceType = "MediaRenderer"
)
type Device struct {
name string
devtype DeviceType
version int
friendlyName string
manufacturer string
manufacturerURL string
modelDescription string
modelName string
modelNumber string
modelURL string
serialNumber string
specVersion string
services ServiceSet
}
// NewDevice creates a new UPnP Device with the given name and type.
// It populates a minimal set of device attributes such as
// FriendlyName, Manufacturer, ModelName and a default version.
//
// Parameters:
//
// name the humanreadable name of the device.
// devtype a unique string identifying the device type
// (used as the Device Identifier as well).
//
// Returns:
//
// *Device a pointer to the freshly allocated Device instance.
// The caller owns the reference and may further
// customise the device by setting additional fields
// or services.
//
// Side effects:
//
// No I/O or external calls are performed. The function
// simply constructs a struct in memory; it is safe to
// call from multiple goroutines.
//
// Example:
//
// // Create a speaker device and register it with a server.
// dev := upnp.NewDevice("LivingRoomSpeaker", "AudioDevice")
// server.RegisterDevice(dev.Name(), dev)
func NewDevice(name string, devtype DeviceType) *Device {
switch devtype {
case MediaServer, MediaRenderer:
dev := &Device{
name: name,
devtype: devtype,
friendlyName: "PMOMusic - " + name,
manufacturer: "Petit Maison Orange",
modelName: "PMOMusic - " + name,
version: 1,
services: make(ServiceSet),
}
return dev
default:
return nil
}
}
func (d *Device) Name() string {
return d.name
}
func (d *Device) SetName(name string) {
d.name = name
}
func (d *Device) SetFriendlyName(name string) {
d.friendlyName = name
}
func (d *Device) SetModelName(name string) {
d.modelName = name
}
func (d *Device) TypeID() string {
return "Device"
}
func (d *Device) DeviceType() DeviceType {
return d.devtype
}
func (d *Device) SetVersion(version int) error {
if version < 1 {
return fmt.Errorf("%s", "version must be greater than or equal to 1")
}
d.version = version
return nil
}
func (d *Device) Version() int {
return d.version
}
func (d *Device) Manufacturer() string {
return d.manufacturer
}
func (d *Device) SetManufacturer(manufacturer string) {
d.manufacturer = manufacturer
}
func (d *Device) ModelName() string {
return d.modelName
}
func (d *Device) AddService(srv *Service) error {
err := d.services.Insert(srv)
return err
}
func (d *Device) NewInstance(server *Server, udn string) *DeviceInstance {
di := &DeviceInstance{
name: d.name,
devtype: d.devtype,
version: d.version,
udn: udn,
server: server,
friendlyName: d.friendlyName,
manufacturer: d.manufacturer,
manufacturerURL: d.manufacturerURL,
modelDescription: d.modelDescription,
modelName: d.modelName,
modelNumber: d.modelNumber,
modelURL: d.modelURL,
serialNumber: d.serialNumber,
specVersion: d.specVersion,
devices: make(DeviceInstanceSet), // vide initialement
services: make(ServiceInstanceSet),
}
for svc := range d.services.All() {
i := svc.NewInstance()
i.device = di
di.services.Insert(i)
}
return di
}

179
pmoupnp/deviceinstance.go Normal file
View File

@@ -0,0 +1,179 @@
package upnp
import (
"fmt"
"net/http"
"runtime"
"gargoton.petite-maison-orange.fr/eric/pmomusic/ssdp"
"github.com/beevik/etree"
log "github.com/sirupsen/logrus"
)
type DeviceInstance struct {
// Identification spécifique à linstance
name string
devtype DeviceType
version int
udn string
server *Server
// Copie figée des infos du Device
friendlyName string
manufacturer string
manufacturerURL string
modelDescription string
modelName string
modelNumber string
modelURL string
serialNumber string
specVersion string
// Sous-devices si le device en contient
devices DeviceInstanceSet
services ServiceInstanceSet
}
func (di *DeviceInstance) Name() string {
return di.name
}
func (di *DeviceInstance) TypeID() string {
return "DeviceInstance"
}
func (di *DeviceInstance) DeviceType() DeviceType {
return di.devtype
}
func (di *DeviceInstance) UDN() string {
return di.udn
}
func (di *DeviceInstance) ServiceType() string {
return fmt.Sprintf("urn:schemas-upnp-org:device:%s:%d", di.devtype, di.version)
}
func (di *DeviceInstance) FriendlyName() string {
return di.friendlyName
}
func (di *DeviceInstance) Manufacturer() string {
return di.manufacturer
}
func (di *DeviceInstance) ModelName() string {
return di.modelName
}
func (di *DeviceInstance) BaseRoute() string {
return fmt.Sprintf("/device/%s/%s", di.DeviceType(), di.UDN())
}
func (di *DeviceInstance) DescriptionURL() string {
return fmt.Sprintf("%s/desc.xml", di.BaseRoute())
}
func (di *DeviceInstance) RegisterURLs() error {
mux, ok := di.server.httpSrv.Handler.(*http.ServeMux)
if mux == nil || !ok {
return fmt.Errorf("❌ Device %s the server handler is not correctly defined", di.Name())
}
mux.HandleFunc(
di.DescriptionURL(),
di.server.ServeXML(di.ToXMLElement),
)
log.Infof(
"✅ Device description for %s available at : %s%s",
di.Name(),
di.server.BaseURL(),
di.DescriptionURL(),
)
for svc := range di.services.All() {
err := svc.RegisterURLs()
if err != nil {
return fmt.Errorf(
"❌ Service %s:%s URL error: %v",
di.Name(),
svc.Name(),
err,
)
}
}
return nil
}
func (di *DeviceInstance) NT() string {
return fmt.Sprintf("uuid:%s::urn:%s", di.UDN(), di.ServiceType())
}
func (di *DeviceInstance) RegisterSSPD() {
osName := runtime.GOOS
arch := runtime.GOARCH
dev := &ssdp.Device{
UUID: di.UDN(),
DeviceType: di.ServiceType(),
Location: fmt.Sprintf("%s%s", di.server.BaseURL(), di.DescriptionURL()),
Server: fmt.Sprintf(
"%s/%s UPnP/1.1 PMOMusic/1.0",
osName, arch,
),
NTs: make([]string, 0, 2+len(di.services)),
}
dev.NTs = append(
dev.NTs,
"upnp:rootdevice",
di.ServiceType(),
)
for s := range di.services.All() {
dev.NTs = append(dev.NTs, s.ServiceType())
}
di.server.sspd.AddDevice(dev)
}
func (di *DeviceInstance) UnregisterSSPD() {
di.server.sspd.RemoveDevice(di.UDN())
}
func (di *DeviceInstance) ToXMLElement() *etree.Element {
elem := etree.NewElement("root")
elem.CreateAttr("xmlns", "urn:schemas-upnp-org:device-1-0")
spec := elem.CreateElement("specVersion")
spec.CreateElement("major").SetText("1")
spec.CreateElement("minor").SetText("0")
device := elem.CreateElement("device")
device.CreateElement("deviceType").SetText(di.ServiceType())
device.CreateElement("friendlyName").SetText(di.FriendlyName())
device.CreateElement("manufacturer").SetText(di.Manufacturer())
device.CreateElement("modelName").SetText(di.ModelName())
device.CreateElement("UDN").SetText("uuid:" + di.UDN())
if len(di.services) > 0 {
device.AddChild(di.services.ToXMLElement())
}
return elem
}
// // NewMediaRendererDescription génère la device description complète
// // comme *etree.Element (racine <root>).
// func NewMediaRendererDescription(udn string, friendlyName string) *etree.Element {
// // Inject serviceList
// device.AddChild(NewMediaRendererServiceList(udn))
// return root
// }

View File

@@ -0,0 +1,23 @@
package avtransport
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
var Play = func() *actions.Action {
ac := actions.NewAction("Play")
ac.AddArgument(
actions.NewInArgument(
"InstanceID",
A_ARG_TYPE_InstanceID,
),
)
ac.AddArgument(
actions.NewInArgument(
"Speed",
TransportPlaySpeed,
),
)
return ac
}()

View File

@@ -0,0 +1,30 @@
package avtransport
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
var SetAVTransportURI = func() *actions.Action {
ac := actions.NewAction("SetAVTransportURI")
ac.AddArgument(
actions.NewInArgument(
"InstanceID",
A_ARG_TYPE_InstanceID,
),
)
ac.AddArgument(
actions.NewInArgument(
"CurrentURI",
AVTransportURI,
),
)
ac.AddArgument(
actions.NewInArgument(
"CurrentURIMetaData",
AVTransportURIMetaData,
),
)
return ac
}()

View File

@@ -0,0 +1,16 @@
package avtransport
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
var Stop = func() *actions.Action {
ac := actions.NewAction("Stop")
ac.AddArgument(
actions.NewInArgument(
"InstanceID",
A_ARG_TYPE_InstanceID,
),
)
return ac
}()

View File

@@ -0,0 +1,10 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var AVTransportURI = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("AVTransportURI")
return ts
}()

View File

@@ -0,0 +1,25 @@
package avtransport
import (
"gargoton.petite-maison-orange.fr/eric/pmomusic/didl"
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
log "github.com/sirupsen/logrus"
)
func _AVTransportURIMetaDataParser(value string) (interface{}, error) {
log.Debug("[avtransport] Parsing AVTransport)")
didl, err := didl.Parse(value)
if err != nil {
return value, err
}
return didl, nil
}
var AVTransportURIMetaData = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("AVTransportURIMetaData")
ts.SetValueParser(_AVTransportURIMetaDataParser)
return ts
}()

View File

@@ -0,0 +1,10 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_InstanceID = func() *sv.StateVariable {
ts := sv.StateType_UI4.NewStateValue("A_ARG_TYPE_InstanceID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_PlaySpeed = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("A_ARG_TYPE_PlaySpeed")
return ts
}()

View File

@@ -0,0 +1,10 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var CurrentTrackDuration = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("CurrentTrackDuration")
return ts
}()

View File

@@ -0,0 +1,10 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var SeekMode = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("SeekMode")
return ts
}()

View File

@@ -0,0 +1,11 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var TransportPlaySpeed = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("TransportPlaySpeed")
ts.AppendAllowedValue("1")
return ts
}()

View File

@@ -0,0 +1,22 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var TransportState = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("TransportState")
ts.SetAllowedValues(
"STOPPED",
"PLAYING",
"RECORDING",
"TRANSITIONING",
"PAUSED_PLAYBACK",
"PAUSED_RECORDING",
"NO_MEDIA_PRESENT",
)
ts.SetSendingEvents()
return ts
}()

View File

@@ -0,0 +1,17 @@
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var TransportStatus = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("TransportStatus")
ts.SetAllowedValues(
"OK",
"ERROR_OCCURRED",
)
ts.SetSendingEvents()
return ts
}()

View File

@@ -0,0 +1,23 @@
package avtransport
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
var AVTransport = func() *upnp.Service {
svc := upnp.NewService("AVTransport")
svc.AddAction(SetAVTransportURI)
svc.AddAction(Play)
svc.AddAction(Stop)
svc.AddVariable(A_ARG_TYPE_InstanceID)
svc.AddVariable(A_ARG_TYPE_PlaySpeed)
svc.AddVariable(AVTransportURI)
svc.AddVariable(AVTransportURIMetaData)
svc.AddVariable(CurrentTrackDuration)
svc.AddVariable(SeekMode)
svc.AddVariable(TransportPlaySpeed)
svc.AddVariable(TransportState)
svc.AddVariable(TransportStatus)
return svc
}()

View File

@@ -0,0 +1,16 @@
package connectionmanager
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
var GetCurrentConnectionIDs = func() *actions.Action {
ac := actions.NewAction("GetCurrentConnectionIDs")
ac.AddArgument(
actions.NewOutArgument(
"ConnectionIDs",
CurrentConnectionIDs,
),
)
return ac
}()

View File

@@ -0,0 +1,64 @@
package connectionmanager
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
var GetCurrentConnectionInfo = func() *actions.Action {
ac := actions.NewAction("GetCurrentConnectionInfo")
ac.AddArgument(
actions.NewInArgument(
"ConnectionIDs",
A_ARG_TYPE_ConnectionID,
),
)
ac.AddArgument(
actions.NewOutArgument(
"RcsID",
A_ARG_TYPE_RcsID,
),
)
ac.AddArgument(
actions.NewOutArgument(
"AVTransportID",
A_ARG_TYPE_AVTransportID,
),
)
ac.AddArgument(
actions.NewOutArgument(
"ProtocolInfo",
A_ARG_TYPE_ProtocolInfo,
),
)
ac.AddArgument(
actions.NewOutArgument(
"PeerConnectionManager",
A_ARG_TYPE_ConnectionManager,
),
)
ac.AddArgument(
actions.NewOutArgument(
"PeerConnectionID",
A_ARG_TYPE_ConnectionID,
),
)
ac.AddArgument(
actions.NewOutArgument(
"Direction",
A_ARG_TYPE_Direction,
),
)
ac.AddArgument(
actions.NewOutArgument(
"Status",
A_ARG_TYPE_ConnectionStatus,
),
)
return ac
}()

View File

@@ -0,0 +1,23 @@
package connectionmanager
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
var GetProtocolInfo = func() *actions.Action {
ac := actions.NewAction("GetProtocolInfo")
ac.AddArgument(
actions.NewOutArgument(
"Source",
SourceProtocolInfo,
),
)
ac.AddArgument(
actions.NewOutArgument(
"Sink",
SinkProtocolInfo,
),
)
return ac
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var AVTransportID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("AVTransportID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_AVTransportID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("A_ARG_TYPE_AVTransportID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_ConnectionID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("A_ARG_TYPE_ConnectionID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_ConnectionManager = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("A_ARG_TYPE_ConnectionManager")
return ts
}()

View File

@@ -0,0 +1,17 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_ConnectionStatus = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("A_ARG_TYPE_ConnectionStatus")
ts.AppendAllowedValue(
"OK",
"ContentFormatMismatch",
"InsufficientBandwidth",
"UnreliableChannel",
"Unknown",
)
return ts
}()

View File

@@ -0,0 +1,11 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_Direction = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("A_ARG_TYPE_Direction")
ts.AppendAllowedValue("Input", "Output")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_ProtocolInfo = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("A_ARG_TYPE_ProtocolInfo")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var A_ARG_TYPE_RcsID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("A_ARG_TYPE_RcsID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var ConnectionID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("ConnectionID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var CurrentConnectionIDs = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("CurrentConnectionIDs")
return ts
}()

View File

@@ -0,0 +1,11 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var Direction = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("Direction")
ts.AppendAllowedValue("Input", "Ouput")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var PeerConnectionID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("PeerConnectionID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var PeerConnectionManager = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("PeerConnectionManager")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var RcsID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("RcsID")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var SinkProtocolInfo = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("SinkProtocolInfo")
return ts
}()

View File

@@ -0,0 +1,10 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var SourceProtocolInfo = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("SourceProtocolInfo")
return ts
}()

View File

@@ -0,0 +1,17 @@
package connectionmanager
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var Status = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("Status")
ts.AppendAllowedValue(
"OK",
"ContentFormatMismatch",
"InsufficientBandwidth",
"UnreliableChannel",
"Unknown",
)
return ts
}()

View File

@@ -0,0 +1,32 @@
package connectionmanager
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
var ConnectionManager = func() *upnp.Service {
svc := upnp.NewService("ConnectionManager")
svc.AddVariable(AVTransportID)
svc.AddVariable(ConnectionID)
svc.AddVariable(CurrentConnectionIDs)
svc.AddVariable(Direction)
svc.AddVariable(PeerConnectionID)
svc.AddVariable(PeerConnectionManager)
svc.AddVariable(RcsID)
svc.AddVariable(SinkProtocolInfo)
svc.AddVariable(SourceProtocolInfo)
svc.AddVariable(Status)
svc.AddVariable(A_ARG_TYPE_AVTransportID)
svc.AddVariable(A_ARG_TYPE_ConnectionID)
svc.AddVariable(A_ARG_TYPE_ConnectionManager)
svc.AddVariable(A_ARG_TYPE_ConnectionStatus)
svc.AddVariable(A_ARG_TYPE_Direction)
svc.AddVariable(A_ARG_TYPE_ProtocolInfo)
svc.AddVariable(A_ARG_TYPE_RcsID)
svc.AddAction(GetCurrentConnectionIDs)
svc.AddAction(GetCurrentConnectionInfo)
svc.AddAction(GetProtocolInfo)
return svc
}()

View File

@@ -0,0 +1,11 @@
package renderingcontrol
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var Channel = func() *sv.StateVariable {
ts := sv.StateType_String.NewStateValue("Channel")
ts.AppendAllowedValue("Master", "LF", "RF")
return ts
}()

View File

@@ -0,0 +1,10 @@
package renderingcontrol
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var InstanceID = func() *sv.StateVariable {
ts := sv.StateType_I4.NewStateValue("InstanceID")
return ts
}()

View File

@@ -0,0 +1,12 @@
package renderingcontrol
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var Mute = func() *sv.StateVariable {
ts := sv.StateType_Boolean.NewStateValue("Mute")
ts.SetSendingEvents()
ts.SetDefault(false)
return ts
}()

View File

@@ -0,0 +1 @@
package renderingcontrol

View File

@@ -0,0 +1,15 @@
package renderingcontrol
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
var Volume = func() *sv.StateVariable {
vol := sv.StateType_UI2.NewStateValue("Volume")
vol.SetRange(0, 100)
vol.SetStep(1)
vol.SetSendingEvents()
return vol
}()

View File

@@ -0,0 +1,14 @@
package renderingcontrol
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
var RenderingControl = func() *upnp.Service {
svc := upnp.NewService("RenderingControl")
svc.AddVariable(InstanceID)
svc.AddVariable(Channel)
svc.AddVariable(Mute)
svc.AddVariable(Volume)
return svc
}()

View File

@@ -0,0 +1,18 @@
package mediarenderer
import (
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
avtransport "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer/AVTransport"
connectionmanager "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer/ConnectionManager"
renderingcontrol "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer/RenderingControl"
)
var FakeRenderer = func() *upnp.Device {
renderer := upnp.NewDevice("FakeRenderer", "MediaRenderer")
renderer.AddService(avtransport.AVTransport)
renderer.AddService(connectionmanager.ConnectionManager)
renderer.AddService(renderingcontrol.RenderingControl)
return renderer
}()

View File

@@ -0,0 +1,38 @@
package actions
import "maps"
type Action struct {
name string
arguments ArgumentSet
}
func NewAction(name string) *Action {
ac := &Action{
name: name,
arguments: make(ArgumentSet),
}
return ac
}
func (a *Action) Name() string {
return a.name
}
func (a *Action) TypeID() string {
return "Action"
}
func (a *Action) AddArgument(arg *Argument) {
a.arguments.Insert(arg)
}
func (a *Action) NewInstance() *ActionInstance {
ac := &ActionInstance{
model: a,
arguments: maps.Clone(a.arguments),
}
return ac
}

View File

@@ -0,0 +1,32 @@
package actions
import "github.com/beevik/etree"
type ActionInstance struct {
model *Action
arguments ArgumentSet
}
func (a *ActionInstance) Name() string {
return a.model.Name()
}
func (a *ActionInstance) TypeID() string {
return "ActionInstance"
}
func (a *ActionInstance) Arguments(name string) (*Argument, bool) {
arg, ok := a.arguments[name]
return arg, ok
}
func (a *ActionInstance) ToXMLElement() *etree.Element {
elem := etree.NewElement("action")
name := elem.CreateElement("name")
name.SetText(a.Name())
elem.AddChild(a.arguments.ToXMLElement())
return elem
}

View File

@@ -0,0 +1,32 @@
package actions
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
"github.com/beevik/etree"
)
type ActionInstanceSet objectstore.ObjectSet[*ActionInstance]
func (m *ActionInstanceSet) Insert(obj *ActionInstance) {
(*objectstore.ObjectSet[*ActionInstance])(m).Insert(obj)
}
func (set *ActionInstanceSet) Contains(obj *ActionInstance) bool {
return (*objectstore.ObjectSet[*ActionInstance])(set).Contains(obj)
}
func (m *ActionInstanceSet) All() iter.Seq[*ActionInstance] {
return (*objectstore.ObjectSet[*ActionInstance])(m).All()
}
func (m *ActionInstanceSet) ToXMLElement() *etree.Element {
elem := etree.NewElement("actionList")
for sv := range m.All() {
elem.AddChild(sv.ToXMLElement())
}
return elem
}

View File

@@ -0,0 +1,25 @@
package actions
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
)
type ActionSet objectstore.ObjectSet[*Action]
func (m *ActionSet) Insert(obj *Action) error {
return (*objectstore.ObjectSet[*Action])(m).Insert(obj)
}
func (m *ActionSet) InsertOrReplace(obj *Action) {
(*objectstore.ObjectSet[*Action])(m).InsertOrReplace(obj)
}
func (set *ActionSet) Contains(obj *Action) bool {
return (*objectstore.ObjectSet[*Action])(set).Contains(obj)
}
func (m *ActionSet) All() iter.Seq[*Action] {
return (*objectstore.ObjectSet[*Action])(m).All()
}

View File

@@ -0,0 +1,100 @@
package actions
import (
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
"github.com/beevik/etree"
)
type Argument struct {
name string
statevariables *statevariables.StateVariable
in bool
out bool
}
func newArgument(name string, statevariable *statevariables.StateVariable) *Argument {
arg := &Argument{
name: name,
statevariables: statevariable,
}
return arg
}
func NewInArgument(name string, statevariable *statevariables.StateVariable) *Argument {
arg := newArgument(name, statevariable)
arg.in = true
return arg
}
func NewOutArgument(name string, statevariable *statevariables.StateVariable) *Argument {
arg := newArgument(name, statevariable)
arg.out = true
return arg
}
func NewInOutArgument(name string, statevariable *statevariables.StateVariable) *Argument {
arg := newArgument(name, statevariable)
arg.in = true
arg.out = true
return arg
}
func (a *Argument) Name() string {
return a.name
}
func (sv Argument) TypeID() string {
return "Argument"
}
func (a *Argument) StateVariable() *statevariables.StateVariable {
return a.statevariables
}
func (a *Argument) IsIn() bool {
return a.in
}
func (a *Argument) IsOut() bool {
return a.out
}
func (a *Argument) ToXMLElement() *etree.Element {
var elem, arg *etree.Element
if a.IsIn() && a.IsOut() {
elem = etree.NewElement("")
arg = elem.CreateElement("argument")
} else {
elem = etree.NewElement("argument")
arg = elem
}
if a.IsIn() {
name := arg.CreateElement("name")
name.SetText(a.Name())
direction := arg.CreateElement("direction")
direction.SetText("in")
relatedStateVariable := arg.CreateElement("relatedStateVariable")
relatedStateVariable.SetText(a.StateVariable().Name())
}
if a.IsIn() && a.IsOut() {
arg = elem.CreateElement("argument")
}
if a.IsOut() {
name := arg.CreateElement("name")
name.SetText(a.Name())
direction := arg.CreateElement("direction")
direction.SetText("out")
relatedStateVariable := arg.CreateElement("relatedStateVariable")
relatedStateVariable.SetText(a.StateVariable().Name())
}
return elem
}

View File

@@ -0,0 +1,32 @@
package actions
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
"github.com/beevik/etree"
)
type ArgumentSet objectstore.ObjectSet[*Argument]
func (m *ArgumentSet) Insert(obj *Argument) {
(*objectstore.ObjectSet[*Argument])(m).Insert(obj)
}
func (set *ArgumentSet) Contains(obj *Argument) bool {
return (*objectstore.ObjectSet[*Argument])(set).Contains(obj)
}
func (m *ArgumentSet) All() iter.Seq[*Argument] {
return (*objectstore.ObjectSet[*Argument])(m).All()
}
func (m *ArgumentSet) ToXMLElement() *etree.Element {
elem := etree.NewElement("argumentList")
for sv := range m.All() {
elem.AddChild(sv.ToXMLElement())
}
return elem
}

View File

@@ -0,0 +1,402 @@
package statevariables
import (
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/url"
"reflect"
"sync"
"time"
"github.com/beevik/etree"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
type notifiable interface {
Name() string
EventToBeSent(name string, value interface{})
}
type StateVarInstance struct {
model *StateVariable
name string
modifiable bool
description string
step interface{} // Step size for incremental state values (e.g., "10")
defaultValue interface{}
valueRange *ValueRange
eventConditions map[string]StateConditionFunc
allowedValues []interface{}
sendEvents bool
parse StringValueParser
marshal ValueSerializer
service notifiable
value interface{}
previousValue interface{}
lastChange time.Time
lastEvent time.Time
mu sync.RWMutex
}
func (instance *StateVarInstance) Name() string {
return instance.model.Name()
}
func (sv *StateVarInstance) TypeID() string {
return "StateVarInstance"
}
func (instance *StateVarInstance) BitSize() int {
return instance.model.BitSize()
}
func (instance *StateVarInstance) Cast(val interface{}) (interface{}, error) {
return instance.model.Cast(val)
}
func (instance *StateVarInstance) HasDefault() bool {
return instance.defaultValue != nil
}
func (instance *StateVarInstance) DefaultValue() interface{} {
return instance.defaultValue
}
func (instance *StateVarInstance) HasRange() bool {
return instance.valueRange != nil
}
func (instance *StateVarInstance) Minimum() interface{} {
if instance.valueRange == nil {
return nil
}
return instance.valueRange.min
}
func (instance *StateVarInstance) Maximum() interface{} {
if instance.valueRange == nil {
return nil
}
return instance.valueRange.max
}
func (instance *StateVarInstance) IsSendingEvents() bool {
return instance.sendEvents
}
func (instance *StateVarInstance) HasAllowedValues() bool {
return len(instance.allowedValues) > 0
}
func (instance *StateVarInstance) AllowedValues() []interface{} {
return instance.allowedValues
}
func (instance *StateVarInstance) HasParser() bool {
return instance.parse != nil
}
func (instance *StateVarInstance) ParseValue(value string) (interface{}, error) {
if instance.parse == nil {
return value, errors.New("Not parsed value")
}
return instance.parse(value)
}
// IsValueInRange checks if a value falls within the defined range.
// Always returns true if no range is set.
// Parameters:
//
// value: Value to check
//
// Returns:
// bool: True if within range or no range defined
func (instance *StateVarInstance) IsValueInRange(value interface{}) (bool, error) {
return instance.model.valueType.InRange(value, instance.valueRange)
}
func (instance *StateVarInstance) IsValueAllowed(value interface{}) (bool, error) {
if !instance.HasAllowedValues() {
return true, nil // No list = any value valid
}
cvalue, err := instance.Cast(value)
if err != nil {
return false, err
}
for _, allowed := range instance.allowedValues {
if reflect.DeepEqual(cvalue, allowed) {
return true, nil
}
}
return false, nil
}
func (instance *StateVarInstance) IsValidValue(value interface{}) (bool, error) {
cvalue, err := instance.Cast(value)
if err != nil {
return false, err
}
inrange, err1 := instance.IsValueInRange(cvalue)
allowed, err2 := instance.IsValueAllowed(cvalue)
if err1 != nil || err2 != nil {
if err1 != nil {
err = err1
} else {
err = err2
}
}
return inrange && allowed, err
}
func (instance *StateVarInstance) HasDescription() bool {
return len(instance.description) > 0
}
func (instance *StateVarInstance) Description() string {
return instance.description
}
func (instance *StateVarInstance) Model() *StateVariable {
return instance.model
}
func (instance *StateVarInstance) IsConstant() bool {
return !instance.modifiable
}
func (instance *StateVarInstance) HasStep() bool {
return instance.step != nil
}
func (instance *StateVarInstance) Step() interface{} {
return instance.step
}
func (instance *StateVarInstance) Value() interface{} {
instance.mu.RLock()
defer instance.mu.RUnlock()
return instance.value
}
func (instance *StateVarInstance) SetValue(val interface{}) error {
cval, err := instance.Cast(val)
if err != nil {
return err
}
if ok, err := instance.IsValidValue(cval); !ok || err != nil {
if err != nil {
return err
}
return fmt.Errorf("Not valid value %v for variable %s", cval, instance.Name())
}
instance.mu.Lock()
defer instance.mu.Unlock()
instance.previousValue = instance.value
instance.value = cval
if instance.ShouldTriggerEvent() {
instance.service.EventToBeSent(instance.Name(), instance.Value())
}
return nil
}
func (instance *StateVarInstance) Incr() error {
if instance.HasStep() {
value, err := instance.model.valueType.Add(instance.Value(), instance.Step())
if err != nil {
return err
}
return instance.SetValue(value)
}
return fmt.Errorf(
"no step for variable %s:%s",
instance.service.Name(),
instance.Name(),
)
}
func (instance *StateVarInstance) Decr() error {
if instance.HasStep() {
value, err := instance.model.valueType.Sub(instance.Value(), instance.Step())
if err != nil {
return err
}
return instance.SetValue(value)
}
return fmt.Errorf(
"no step for variable %s:%s",
instance.service.Name(),
instance.Name(),
)
}
// ShouldTriggerEvent vérifie toutes les conditions
func (instance *StateVarInstance) ShouldTriggerEvent() bool {
if instance.IsSendingEvents() {
for name, condition := range instance.model.eventConditions {
if !condition(instance) {
log.Debugf(
"State variable %s:%s event condition %s not true",
instance.service.Name(),
instance.Name(),
name,
)
return false
}
}
return true
}
return false
}
func (sv *StateVarInstance) 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.valueToString(sv.Value()))
return propSet
}
// ToXMLElement generates the complete XML representation of the state variable
// Returns an etree.Element that can be serialized to XML
func (sv *StateVarInstance) ToXMLElement() *etree.Element {
// Create root <stateVariable> element
elem := etree.NewElement("stateVariable")
// Add sendEvents attribute (UPnP eventing capability)
if sv.sendEvents {
elem.CreateAttr("sendEvents", "yes") // Enable event notifications
} else {
elem.CreateAttr("sendEvents", "no") // Disable event notifications
}
name := elem.CreateElement("name")
name.SetText(sv.Name())
// Add data type element
dataType := elem.CreateElement("dataType")
dataType.SetText(sv.model.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 *StateVarInstance) valueToString(val interface{}) string {
if val == nil {
return "" // Safeguard against nil values
}
// Type-specific formatting for UPnP compliance
switch sv.model.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)
}

View File

@@ -0,0 +1,36 @@
package statevariables
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
"github.com/beevik/etree"
)
type StateVarInstanceSet objectstore.ObjectSet[*StateVarInstance]
func (m *StateVarInstanceSet) Insert(obj *StateVarInstance) error {
return (*objectstore.ObjectSet[*StateVarInstance])(m).Insert(obj)
}
func (m *StateVarInstanceSet) InsertOrReplace(obj *StateVarInstance) {
(*objectstore.ObjectSet[*StateVarInstance])(m).InsertOrReplace(obj)
}
func (m *StateVarInstanceSet) Contains(obj *StateVarInstance) bool {
return (*objectstore.ObjectSet[*StateVarInstance])(m).Contains(obj)
}
func (m *StateVarInstanceSet) All() iter.Seq[*StateVarInstance] {
return (*objectstore.ObjectSet[*StateVarInstance])(m).All()
}
func (m *StateVarInstanceSet) ToXMLElement() *etree.Element {
elem := etree.NewElement("serviceStateTable")
for sv := range m.All() {
elem.AddChild(sv.ToXMLElement())
}
return elem
}

View File

@@ -0,0 +1,423 @@
package statevariables
import (
"fmt"
"maps"
"reflect"
"slices"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
type EventType string
type StateConditionFunc func(instance *StateVarInstance) bool
type StringValueParser func(value string) (interface{}, error)
type ValueSerializer func(value interface{}) (string, error)
type StateVariable struct {
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 map[string]StateConditionFunc
description string
defaultValue interface{}
valueRange *ValueRange
allowedValues []interface{}
sendEvents bool
parse StringValueParser
marshal ValueSerializer
}
// BitSize returns the number of bits that will be used to represent values
// when this type is used in an UPnP State Variable. The returned value can be
// either 8, 16, 24, 32, or 64, depending on whether t is Byte, Boolean, I2, Ui2,
// I4, Ui4 respectively. If none of these types match, it will return -1.
func (sv StateVariable) BitSize() int {
return sv.valueType.BitSize()
}
// Name returns the state variable's name (e.g., "Volume", "Brightness").
func (sv StateVariable) Name() string {
return sv.name
}
func (sv StateVariable) TypeID() string {
return "StateVariable"
}
// Type returns the UPnP data type of the state variable.
func (state *StateVariable) Type() StateVarType {
return state.valueType
}
func (state *StateVariable) AddEventCondition(name string, condition StateConditionFunc) {
state.eventConditions[name] = condition
}
func (state *StateVariable) 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 *StateVariable) ClearEventConditions() {
state.eventConditions = make(map[string]StateConditionFunc)
}
func (sv *StateVariable) 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 *StateVariable) SetDefault(value interface{}) error {
var err error
var valid bool
if valid, err = state.IsValidValue(value); valid && err == nil {
cvalue, _ := state.valueType.Cast(value)
state.defaultValue = cvalue
log.Debugf("🐞 Setting default value for %v to %v", state.name, cvalue)
return nil
}
return fmt.Errorf("invalid default value for %v (%v) : %v", state.name, value, err)
}
func (state *StateVariable) HasDefault() bool {
return state.defaultValue != nil
}
func (state *StateVariable) DefaultValue() interface{} {
if !state.HasDefault() {
return state.valueType.DefaultValue()
}
return state.defaultValue
}
// HasRange indicates if a value range constraint is defined.
// Returns true if min/max boundaries are set.
func (state *StateVariable) HasRange() bool {
return state.valueRange != nil
}
// Maximum returns the upper bound of the value range.
// Returns nil if no range is defined.
func (state *StateVariable) Maximum() interface{} {
if state.valueRange == nil {
return nil
}
return state.valueRange.max
}
// Minimum returns the lower bound of the value range.
// Returns nil if no range is defined.
func (state *StateVariable) Minimum() interface{} {
if state.valueRange == nil {
return nil
}
return state.valueRange.min
}
func (state *StateVariable) SetValueParser(parser StringValueParser) {
state.parse = parser
}
// SetRange defines the inclusive value range [min, max].
// Validates and casts values to the state variable's type.
//
// Parameters:
//
// min: Lower boundary value
// max: Upper boundary value
//
// Returns:
//
// error: If values can't be cast to the type or are nil
//
// Example:
//
// err := volumeState.SetRange(0, 100) // 0-100 range for volume
func (state *StateVariable) SetRange(min, max interface{}) error {
if min == nil || max == nil {
return fmt.Errorf("min and max must not be nil")
}
limits, err := state.valueType.ValueRange(min, max)
if err != nil {
return fmt.Errorf("setting range: %v", err)
}
state.valueRange = limits
log.Debugf("🐞 Setting range of %s to [%v, %v]", state.name, min, max)
return nil
}
// UpdateMinimalValue dynamically updates the lower range boundary.
// Requires an existing range to be set.
//
// Parameters:
//
// value: New minimum value
//
// Returns:
//
// error: If no range exists or value can't be cast
func (state *StateVariable) UpdateMinimalValue(value interface{}) error {
if state.valueRange == nil {
return fmt.Errorf("no range set for value %v", state.name)
}
cvalue, err := state.valueType.Cast(value)
if err != nil {
return fmt.Errorf("casting value: %v", err)
}
state.valueRange.min = cvalue
log.Debugf("🐞 Updating minimal value of %s to %v", state.name, cvalue)
return nil
}
// UpdateMaximalValue dynamically updates the upper range boundary.
// Requires an existing range to be set.
//
// Parameters:
//
// value: New maximum value
//
// Returns:
//
// error: If no range exists or value can't be cast
func (state *StateVariable) UpdateMaximalValue(value interface{}) error {
if state.valueRange == nil {
return fmt.Errorf("no range set for value %v", state.name)
}
cvalue, err := state.valueType.Cast(value)
if err != nil {
return fmt.Errorf("casting value: %v", err)
}
state.valueRange.max = cvalue
log.Debugf("🐞 Updating maximal value of %s to %v", state.name, cvalue)
return nil
}
// IsSendingEvents indicates if state changes trigger UPnP events.
func (state *StateVariable) IsSendingEvents() bool {
return state.sendEvents
}
// SetSendingEvents enables event notifications for state changes.
func (state *StateVariable) SetSendingEvents() {
state.sendEvents = true
log.Debugf("🐞 Enabling event sending for %s", state.name)
}
// UnsetSendingEvents disables event notifications for state changes.
func (state *StateVariable) UnsetSendingEvents() {
state.sendEvents = false
log.Debugf("🐞 Disabling event sending for %s", state.name)
}
// HasAllowedValues indicates if an allowed value list is defined.
func (state *StateVariable) HasAllowedValues() bool {
return len(state.allowedValues) > 0
}
// AllowedValues returns the list of permitted values.
// Returns an empty slice if no values are defined.
func (state *StateVariable) AllowedValues() []interface{} {
return state.allowedValues
}
// AppendAllowedValue adds values to the permitted value list.
// Values are cast to the state variable's type before adding.
//
// Parameters:
//
// value: One or more values to add
//
// Returns:
//
// error: If any value can't be cast to the type
//
// Example:
//
// err := state.AppendAllowedValue("PLAYING", "PAUSED", "STOPPED")
func (state *StateVariable) AppendAllowedValue(value ...interface{}) error {
state.allowedValues = slices.Grow(state.allowedValues, len(value))
for _, v := range value {
cv, err := state.valueType.Cast(v)
if err != nil {
return fmt.Errorf("casting allowed value: %v", err)
}
state.allowedValues = append(state.allowedValues, cv)
}
log.Debugf("🐞 Added allowed values to %s: %v", state.name, value)
return nil
}
func (state *StateVariable) HasDescription() bool {
return len(state.description) > 0
}
func (state *StateVariable) Description() string {
return state.description
}
func (state *StateVariable) SetDescription(desc string) {
state.description = strings.TrimSpace(desc)
}
func (state *StateVariable) IsConstant() bool {
return !state.modifiable
}
func (state *StateVariable) SetConstant() {
state.modifiable = false
}
func (state *StateVariable) SetModifiable() {
state.modifiable = true
}
func (state *StateVariable) SetStep(step interface{}) error {
// Validation que le step correspond au type de la variable
if _, err := state.valueType.Cast(step); err != nil {
return fmt.Errorf("invalid step type: %v", err)
}
state.step = step
return nil
}
func (state *StateVariable) UnsetStep() {
state.step = nil
}
func (state *StateVariable) HasStep() bool {
return state.step != nil
}
func (state *StateVariable) Step() interface{} {
return state.step
}
func (state *StateVariable) SetAllowedValues(allowed ...interface{}) {
state.ClearAllowedValues()
state.AppendAllowedValues(allowed...)
}
func (state *StateVariable) AppendAllowedValues(allowed ...interface{}) error {
state.allowedValues = slices.Grow(state.allowedValues, len(allowed))
var err error
for _, val := range allowed {
val, err = state.valueType.Cast(val)
if err != nil {
return err
}
state.allowedValues = append(state.allowedValues, val)
}
return nil
}
func (state *StateVariable) ClearAllowedValues() {
state.allowedValues = make([]interface{}, 0)
}
// bool: True if within range or no range defined
func (state *StateVariable) IsValueInRange(value interface{}) (bool, error) {
return state.valueType.InRange(value, state.valueRange)
}
func (state *StateVariable) IsValueAllowed(value interface{}) (bool, error) {
if !state.HasAllowedValues() {
return true, nil // No list = any value valid
}
cvalue, err := state.Cast(value)
if err != nil {
return false, err
}
for _, allowed := range state.allowedValues {
if reflect.DeepEqual(cvalue, allowed) {
return true, nil
}
}
return false, nil
}
func (state *StateVariable) IsValidValue(value interface{}) (bool, error) {
cvalue, err := state.Cast(value)
if err != nil {
return false, err
}
inrange, err1 := state.IsValueInRange(cvalue)
allowed, err2 := state.IsValueAllowed(cvalue)
if err1 != nil || err2 != nil {
if err1 != nil {
err = err1
} else {
err = err2
}
}
return inrange && allowed, err
}
func (state *StateVariable) NewInstance() *StateVarInstance {
instance := &StateVarInstance{
model: state,
name: state.name,
modifiable: state.modifiable,
description: state.description,
step: state.step,
defaultValue: state.defaultValue,
eventConditions: maps.Clone(state.eventConditions),
allowedValues: slices.Clone(state.allowedValues),
sendEvents: state.sendEvents,
parse: state.parse,
marshal: state.marshal,
value: state.DefaultValue(),
lastChange: time.Now(),
lastEvent: time.Unix(int64(1718985600), 0).UTC(),
}
if state.HasRange() {
instance.valueRange = &ValueRange{
min: state.valueRange.min,
max: state.valueRange.max,
}
}
return instance
}

View File

@@ -0,0 +1,17 @@
package statevariables
var TransferStatus = func() *StateVariable {
ts := StateType_String.NewStateValue("TransferStatus")
ts.SetAllowedValues(
"COMPLETED",
"ERROR",
"IN_PROGRESS",
"NONE",
)
ts.SetSendingEvents()
return ts
}()

View File

@@ -0,0 +1,39 @@
package statevariables
// Add performs addition operation on the given parameters 'a' and 'b'. It calls the
// corresponding method from valueType which is assumed to be an interface that
// provides methods for arithmetic operations. This function returns the result of
// the operation or an error if any occurs during computation. Side effects might
// include modifying the state of the system, but this depends on the actual implementation
// of the valueType's Add method. Errors might occur due to invalid input parameters or
// failures in the addition operation itself. The function does not handle edge cases
// and therefore should be used with caution. Here is an example usage:
//
// result, err := sv.Add(5, 3)
// if err != nil {
// // Handle error
// } else {
// // Use result
// }
func (sv StateVariable) Add(a, b interface{}) (interface{}, error) {
return sv.valueType.Add(a, b)
}
// Sub performs subtraction operation on the given parameters 'a' and 'b'. It follows
// similar semantics as in the Add method, but for subtraction instead of addition.
func (sv StateVariable) Sub(a, b interface{}) (interface{}, error) {
return sv.valueType.Sub(a, b)
}
// Mul performs multiplication operation on the given parameters 'a' and 'b'. It follows
// similar semantics as in the Add method, but for multiplication instead of addition.
func (sv StateVariable) Mul(a, b interface{}) (interface{}, error) {
return sv.valueType.Mul(a, b)
}
// Div performs division operation on the given parameters 'a' and 'b'. It follows similar
// semantics as in the Add method, but for division instead of addition. Please note that
// division by zero is undefined and will result in an error being returned from valueType.Div call.
func (sv StateVariable) Div(a, b interface{}) (interface{}, error) {
return sv.valueType.Div(a, b)
}

View File

@@ -0,0 +1,29 @@
package statevariables
// Cast transforms any given value into an interface suitable for use in a StateValue object, using
// the underlying type's specific casting rules. It will return an error if the transformation fails or
// if the provided interface is not supported by the ValueType of the StateValue.
// This function does NOT mutate the original value it receives as input.
//
// The parameter 'val' is an interface that needs to be cast into a form compatible with the internal
// state representation used by the StateValue object.
//
// It returns an interface and an error: if the casting operation was successful, the first return value will
// be the casted version of 'val', and the second one (an error) will be nil. If the casting fails, it will
// return a nil for the first value and an appropriate error for the second one.
//
// This function should not modify its input value. It is idempotent and always returns consistent results given
// the same inputs. However, if the provided interface 'val' does not match with the ValueType of StateValue, it
// will return an error.
//
// Example usage:
//
// state := upnp.NewStateValue(upnp.NewTime())
// castedVal, err := state.Cast("2022-12-31")
// if err != nil {
// log.Println(err)
// return
// }
func (sv *StateVariable) Cast(val interface{}) (interface{}, error) {
return sv.valueType.Cast(val)
}

View File

@@ -0,0 +1,13 @@
package statevariables
func (sv StateVariable) Cmp(a, b interface{}) (int, error) {
return sv.valueType.Cmp(a, b)
}
func (sv StateVariable) Equal(a, b interface{}) (int, error) {
return sv.valueType.Cmp(a, b)
}
func (sv StateVariable) InRange(val interface{}, interval *ValueRange) (bool, error) {
return sv.valueType.InRange(val, interval)
}

View File

@@ -0,0 +1,158 @@
package statevariables
// IsNumeric checks whether a given StateValue model represents a numeric value 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 StateValue represents a numeric value; false
// otherwise.
func (sv StateVariable) IsNumeric() bool {
return sv.valueType.IsNumeric()
}
// IsInteger checks if the state variable type is integer or not.
//
// It returns a boolean value indicating whether the provided StateValue
// is an integer type or not.
//
// Returns: bool: If the state variable type is any of the defined integer types
// (UI1, UI2, UI4, I1, I2, I4, Int), it returns true. Otherwise, it returns false.
func (sv StateVariable) IsInteger() bool {
return sv.valueType.IsInteger()
}
// IsSignedInt checks if the state value type is a signed int.
//
// The return value will be a boolean indicating whether the state value type is
// a signed integer (true) or not (false).
func (sv StateVariable) IsSignedInt() bool {
return sv.valueType.IsSignedInt()
}
// IsUnsignedInt checks if the state value type represents an unsigned integer.
// The method returns a boolean indicating whether the state value type is an
// unsigned integer.
func (sv StateVariable) IsUnsignedInt() bool {
return sv.valueType.IsUnsignedInt()
}
// IsFloat returns true if the StateValue's value type represents a floating point
// number; false otherwise.
func (sv StateVariable) IsFloat() bool {
return sv.valueType.IsFloat()
}
// IsBool checks if the state value is of boolean type.
//
// Parameters:
//
// None
//
// Returns:
//
// (bool) : Indicates whether the StateValue is of boolean type or not.
func (sv StateVariable) IsBool() bool {
return sv.valueType.IsBool()
}
// IsString checks if the underlying value type of a StateValue object
// represents a string.
//
// Parameters:
// - None.
//
// Returns:
//
// bool: Indicates whether the underlying value type is a string or not.
//
// Side Effects:
// - This function does not modify any state. It only reads and returns a boolean value.
//
// Errors:
// - This function does not return an error, so you don't have to check for errors.
//
// Edge Cases:
// - If the underlying type of the StateValue is not TypeString,
// this function will return false as expected.
//
// Usage example:
//
// state := upnp.StateValue{valueType: upnp.TypeInt}
// fmt.Println(state.IsString()) // Outputs: false
func (sv StateVariable) IsString() bool {
return sv.valueType.IsString()
}
// IsTime reports whether this state value represents a time instance.
//
// The function returns true if and only if the underlying type of the
// StateValue is TypeTime, else false. This method does not check for other
// types that are convertible to Time as it's assumed that these would be
// handled by the ConvertToType method beforehand.
//
// No side effects: this function is pure and doesn't change any state.
//
// Errors: This function does not return an error, so you don't have to check
// for errors. However, note that TypeTime conversion might fail if the
// StateValue isn't convertible to Time; use the ConvertToType method in such
// cases.
//
// Edge cases: If the underlying type of the StateValue is not TypeTime or is a
// non-convertible time, this function will return false as expected.
//
// Usage example:
//
// state := upnp.StateValue{valueType: upnp.TypeInt}
// fmt.Println(state.IsTime())
//
// Outputs: false
func (sv StateVariable) IsTime() bool {
return sv.valueType.IsTime()
}
// IsUUID checks if the state value type is a UUID (Universally Unique
// Identifier).
//
// It returns true if the underlying type of the StateValue object represents a
// UUID; false otherwise.
//
// Returns: bool: Indicates whether the StateValue is of UUID type or not.
func (sv StateVariable) IsUUID() bool {
return sv.valueType.IsUUID()
}
// IsURI checks if the state value type is a URI.
// The method returns a boolean indicating whether the state value type represents
// a Uniform Resource Identifier (URI) or not.
//
// Returns: bool: Indicates whether the StateValue is of URI type or not.
func (sv StateVariable) IsURI() bool {
return sv.valueType.IsURI()
}
// IsBinary checks if the state value type is binary or not.
//
// The function returns a boolean indicating whether the provided StateValue's
// value type is of binary format (bin.base64 or bin.hex).
//
// Returns: bool: If the underlying value type of the StateValue object is
// either TypeBinBase64 or TypeBinHex, this method will return true; otherwise,
// it returns false.
func (sv StateVariable) IsBinary() bool {
return sv.valueType.IsBinary()
}
// IsComparable function checks if a state variable is comparable or not.
//
// It returns false for binary types (bin.base64 and bin.hex)
// as they are non-comparable. For all other types, it returns true indicating
// that these types can be compared.
// //
// Returns: bool: A boolean value indicating whether the given StateValue is
// comparable or not. True means it's comparable, False means it isn't.
func (sv StateVariable) IsComparable() bool {
return sv.valueType.IsComparable()
}

View File

@@ -0,0 +1,25 @@
package statevariables
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
)
type StateVariableSet objectstore.ObjectSet[*StateVariable]
func (m *StateVariableSet) Insert(obj *StateVariable) error {
return (*objectstore.ObjectSet[*StateVariable])(m).Insert(obj)
}
func (m *StateVariableSet) InsertOrReplace(obj *StateVariable) {
(*objectstore.ObjectSet[*StateVariable])(m).InsertOrReplace(obj)
}
func (set *StateVariableSet) Contains(obj *StateVariable) bool {
return (*objectstore.ObjectSet[*StateVariable])(set).Contains(obj)
}
func (m *StateVariableSet) All() iter.Seq[*StateVariable] {
return (*objectstore.ObjectSet[*StateVariable])(m).All()
}

View File

@@ -0,0 +1,194 @@
// package stateVariables provides comprehensive handling of UPnP state variable types.
// It includes type identification, value casting, comparison, and range validation
// for all standard UPnP state variable types.
package statevariables
import (
"strings"
"time"
)
// StateVarType represents UPnP state variable types with corresponding Go type mappings.
type StateVarType int
// Constants defining all supported UPnP state variable types
const (
StateType_Unknown StateVarType = iota
StateType_UI1 // Unsigned 8-bit integer (Go: uint8)
StateType_UI2 // Unsigned 16-bit integer (Go: uint16)
StateType_UI4 // Unsigned 32-bit integer (Go: uint32)
StateType_I1 // Signed 8-bit integer (Go: int8)
StateType_I2 // Signed 16-bit integer (Go: int16)
StateType_I4 // Signed 32-bit integer (Go: int32)
StateType_Int // Synonymous with i4 (Go: int32)
StateType_R4 // 32-bit floating point (Go: float32)
StateType_R8 // 64-bit floating point (Go: float64)
StateType_Number // Synonymous with r8 (Go: float64)
StateType_Fixed14_4 // Fixed-point decimal (Go: float64)
StateType_Char // Single Unicode character (Go: rune)
StateType_String // Character string (Go: string)
StateType_Boolean // Boolean value (Go: bool)
StateType_BinBase64 // Base64-encoded binary (Go: []byte)
StateType_BinHex // Hex-encoded binary (Go: []byte)
StateType_Date // Date (YYYY-MM-DD) (Go: time.Time)
StateType_DateTime // DateTime without timezone (Go: time.Time)
StateType_DateTimeTZ // DateTime with timezone (Go: time.Time)
StateType_Time // Time without timezone (Go: time.Time)
StateType_TimeTZ // Time with timezone (Go: time.Time)
StateType_UUID // Universally unique identifier (Go: uuid.UUID)
StateType_URI // Uniform Resource Identifier (Go: *url.URL)
)
// typeNames maps UPnP XML type names to StateVarType constants
var typeNames = map[string]StateVarType{
"ui1": StateType_UI1,
"ui2": StateType_UI2,
"ui4": StateType_UI4,
"i1": StateType_I1,
"i2": StateType_I2,
"i4": StateType_I4,
"int": StateType_Int,
"r4": StateType_R4,
"r8": StateType_R8,
"number": StateType_Number,
"fixed.14.4": StateType_Fixed14_4,
"char": StateType_Char,
"string": StateType_String,
"boolean": StateType_Boolean,
"bin.base64": StateType_BinBase64,
"bin.hex": StateType_BinHex,
"date": StateType_Date,
"dateTime": StateType_DateTime,
"dateTime.tz": StateType_DateTimeTZ,
"time": StateType_Time,
"time.tz": StateType_TimeTZ,
"uuid": StateType_UUID,
"uri": StateType_URI,
}
// typeStrings provides string representations for StateVarType constants
var typeStrings = [...]string{
"unknown",
"ui1",
"ui2",
"ui4",
"i1",
"i2",
"i4",
"int",
"r4",
"r8",
"number",
"fixed.14.4",
"char",
"string",
"boolean",
"bin.base64",
"bin.hex",
"date",
"dateTime",
"dateTime.tz",
"time",
"time.tz",
"uuid",
"uri",
}
// StateVarTypeFactory takes a string and attempts to build from it a valid
// StateVarType. The input string is cleaned before processing -
// leading/trailing spaces are trimmed, the case is lowered for comparison with
// known types, and if no match is found 'StateType_Unknown' is returned.
func StateVarTypeFactory(s string) StateVarType {
s = strings.ToLower(strings.TrimSpace(s))
if val, ok := typeNames[s]; ok {
return val
}
return StateType_Unknown
}
// 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]
}
return "unknown"
}
// BitSize returns the bit size of the StateVarType value, or -1 if not numeric.
// The possible return values are 8 for StateTypes I1, UI1, and 64 for others.
func (t StateVarType) BitSize() int {
// If t isn't numeric, return -1
if !t.IsNumeric() {
return -1
}
// Check the value of t and return the appropriate bit size
switch t {
case StateType_I1, StateType_UI1:
return 8
case StateType_I2, StateType_UI2:
return 16
case StateType_I4, StateType_UI4, StateType_Int, StateType_R4:
return 32
case StateType_R8, StateType_Number, StateType_Fixed14_4:
return 64
default:
return 64
}
}
// NewStateValue creates and returns a new StateValue struct instance with the given name
// and the receiver's state variable type. The created StateValue is initialized with an
// empty map for event conditions. If name is an empty string, it will cause panic in later
// usage. Name is typically used to identify or represent a specific value or condition
// associated with the variable type 't'.
func (t StateVarType) NewStateValue(name string) *StateVariable {
return &StateVariable{
name: name,
valueType: t,
eventConditions: make(map[string]StateConditionFunc),
}
}
func (t StateVarType) DefaultValue() interface{} {
switch t {
case StateType_Unknown:
case StateType_UI1, StateType_UI2, StateType_UI4:
return uint64(0)
case StateType_I1, StateType_I2, StateType_I4, StateType_Int:
return int64(0)
case StateType_R4, StateType_R8, StateType_Number, StateType_Fixed14_4:
return float64(0)
case StateType_Char, StateType_String:
return ""
case StateType_Boolean:
return false
case StateType_BinBase64:
return ""
case StateType_BinHex:
return ""
case StateType_Date:
return time.Unix(int64(1718985600), 0).UTC()
case StateType_DateTime:
return time.Unix(int64(1718985600), 0).UTC()
case StateType_DateTimeTZ:
return time.Unix(int64(1718985600), 0).UTC()
case StateType_Time:
return time.Unix(int64(1718985600), 0).UTC()
case StateType_TimeTZ:
return time.Unix(int64(1718985600), 0).UTC()
case StateType_UUID:
return ""
case StateType_URI:
return ""
}
return nil
}

View File

@@ -0,0 +1,73 @@
package statevariables
// 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)
}
// Sub subtracts 'b' from 'a'. It converts both values to numeric types
// and then performs a subtraction operation, casting the result back to its
// original type. If either conversion fails or an unsupported type is used,
// it returns an error.
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)
}
// Mul takes in two interface types 'a' and 'b', multiplies them together
// and returns the result along with any error encountered during this
// process. If either of the inputs is not compatible with numeric values, an
// error will be returned. The multiplication operation is performed between two
// numbers represented as 'float64' types (since Go does not support generic
// types on its own). The resulting value will be cast to the type represented
// by the receiver of this method 't'. If a casting error occurs, it will also
// be returned along with nil for the result.
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)
}
// Div performs division between the provided arguments 'a' and 'b'. The function casts both operands to their numeric equivalents using valuesToNumericOperands() before performing the division.
//
// Parameters:
// - a: first operand of type interface{}, can be of any type, will be converted if necessary
// - b: second operand of type interface{}, can be of any type, will be converted if necessary
//
// Returns:
// - result: the division result in numeric form after casting it with function t.Cast()
// - err: error that might occur during the conversion or division operation
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)
}

View File

@@ -0,0 +1,153 @@
package statevariables
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// Cast converts a value to the Go type corresponding to the UPnP type.
// Supports conversion from various primitive types and strings.
// Returns an error for unsupported conversions or invalid values.
//
// Examples:
// - StateType_UI2.Cast(42) // uint16(42), nil
// - StateType_Boolean.Cast("true") // true, nil
// - StateType_UI1.Cast(300) // nil, error (overflow)
func (t StateVarType) Cast(val interface{}) (interface{}, error) {
switch t {
case StateType_UI1:
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, 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, 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, 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, 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, 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, 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, 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, err := toBool(val)
if err != nil {
return nil, fmt.Errorf("cannot cast %v (%T) to Boolean", val, val)
}
return b, nil
case StateType_Char:
switch s := val.(type) {
case string:
if len(s) != 1 {
return nil, fmt.Errorf("invalid Char: string too long %q", s)
}
return rune(s[0]), nil
case rune:
return s, nil
default:
return nil, fmt.Errorf("cannot cast %v (%T) to Char", val, val)
}
case StateType_String:
return fmt.Sprint(val), nil
case StateType_UUID:
switch val := val.(type) {
case uuid.UUID:
return val, nil
case string:
u, err := uuid.Parse(strings.TrimSpace(val))
if err != nil {
return nil, fmt.Errorf("invalid UUID %v: %v", val, err)
}
return u, nil
default:
return nil, fmt.Errorf("cannot cast %v (%T) to UUID", val, val)
}
case StateType_URI:
switch val := val.(type) {
case *url.URL:
return val, nil
case string:
u, err := url.Parse(strings.TrimSpace(val))
if err != nil {
return nil, fmt.Errorf("invalid URI %v: %v", val, err)
}
return u, nil
default:
return nil, fmt.Errorf("cannot cast %v (%T) to URI", val, val)
}
case StateType_BinBase64, StateType_BinHex:
switch v := val.(type) {
case []byte:
return v, nil
case string:
return decodeBinary(t, v)
default:
return nil, fmt.Errorf("cannot cast %v (%T) to binary", val, val)
}
case StateType_Date, StateType_DateTime, StateType_DateTimeTZ,
StateType_Time, StateType_TimeTZ:
switch v := val.(type) {
case time.Time:
return v, nil
case string:
return parseUPnPTime(t, v)
default:
return nil, fmt.Errorf("cannot cast %v (%T) to time", val, val)
}
default:
return nil, fmt.Errorf("unsupported type: %v", t)
}
}

View File

@@ -0,0 +1,205 @@
package statevariables
import (
"bytes"
"fmt"
"log"
"strings"
)
// Cmp compares two values of a given type 'StateVarType'. The comparison is
// made based on the specific type of 'a' and 'b'. It returns an integer
// indicating whether 'a' is less than, equal to, or greater than 'b'. If 'a' is
// less than 'b', it returns -1. If they are equal, it returns 0. If 'a' is
// greater than 'b', it returns 1. It also returns an error if any of the values
// can't be cast to the required type or if comparison isn't supported for the
// given type 'StateVarType'.
//
// - t: StateVarType representing the specific type to use for comparison.
// - a, b: The values to compare. They should be of type interface{} as they could be
// any valid Go type.
//
// Returns: An integer indicating the result of the comparison (-1 if 'a' is
// less than 'b', 0 if they are equal, and 1 if 'a' is greater than 'b'). It
// also returns an error if any values can't be cast or if comparison isn't
// supported for the given type.
//
// Example:
//
// result, err := t.Cmp(int32(5), int32(7)) // Returns -1 and nil error as 5 is less than 7.
func (t StateVarType) Cmp(a, b interface{}) (int, error) {
a, err1 := t.Cast(a)
b, err2 := t.Cast(b)
if err1 != nil || err2 != nil {
log.Fatalf("Failed to cast for comparison: %v vs %v (errors: %v, %v)", a, b, err1, err2)
}
switch {
case t.IsInteger():
ai, err := toInt(a, t.BitSize())
if err != nil {
return 0, fmt.Errorf("invalid int value for a: %w", err)
}
bi, err := toInt(b, t.BitSize())
if err != nil {
return 0, fmt.Errorf("invalid int value for b: %w", err)
}
return cmpInt(ai, bi), nil
case t.IsUnsignedInt():
ai, err := toUint(a, t.BitSize())
if err != nil {
return 0, fmt.Errorf("invalid uint value for a: %w", err)
}
bi, err := toUint(b, t.BitSize())
if err != nil {
return 0, fmt.Errorf("invalid uint value for b: %w", err)
}
return cmpUint(ai, bi), nil
case t.IsFloat():
af, err := toFloat(a, t.BitSize())
if err != nil {
return 0, fmt.Errorf("invalid float value for a: %w", err)
}
bf, err := toFloat(b, t.BitSize())
if err != nil {
return 0, fmt.Errorf("invalid float value for b: %w", err)
}
return cmpFloat64(af, bf), nil
case t == StateType_Boolean:
ab, err := toBool(a)
if err != nil {
return 0, fmt.Errorf("invalid bool value for a: %w", err)
}
bb, err := toBool(b)
if err != nil {
return 0, fmt.Errorf("invalid bool value for b: %w", err)
}
return cmpBool(ab, bb), nil
case t == StateType_String || t == StateType_Char:
as, err := toString(a)
if err != nil {
return 0, fmt.Errorf("invalid string value for a")
}
bs, err := toString(b)
if err != nil {
return 0, fmt.Errorf("invalid string value for b")
}
return strings.Compare(as, bs), nil
case t.IsTime():
at, err := toTime(a)
if err != nil {
return 0, fmt.Errorf("invalid time value for a")
}
bt, err := toTime(b)
if err != nil {
return 0, fmt.Errorf("invalid time value for b")
}
return cmpTime(at, bt), nil
default:
return 0, fmt.Errorf("comparison not supported for type %v", t)
}
}
func (t StateVarType) Equal(a, b interface{}) (bool, error) {
switch {
case t.IsInteger():
ai, err1 := toInt(a, 64)
bi, err2 := toInt(b, 64)
if err1 != nil || err2 != nil {
return false, fmt.Errorf("invalid integer value for type %s", t.String())
}
return ai == bi, nil
case t.IsFloat():
af, err := toFloat(a, 64)
if err != nil {
return false, fmt.Errorf("invalid float value for type %s: %v", t.String(), err)
}
bf, err := toFloat(b, 64)
if err != nil {
return false, fmt.Errorf("invalid float value for type %s: %v", t.String(), err)
}
return af == bf, nil
case t.IsString():
as, ok1 := a.(string)
bs, ok2 := b.(string)
if !ok1 || !ok2 {
return false, fmt.Errorf("invalid string value for type %s", t.String())
}
return as == bs, nil
case t.IsBool():
ab, err1 := toBool(a)
bb, err2 := toBool(b)
if err1 != nil || err2 != nil {
return false, fmt.Errorf("invalid boolean value for type %s", t.String())
}
return ab == bb, nil
case t.IsTime():
at, err1 := toTime(a)
bt, err2 := toTime(b)
if err1 != nil || err2 != nil {
return false, fmt.Errorf("invalid time.Time value for type %s", t.String())
}
return at.Equal(bt), nil
case t.IsUUID():
au, err1 := toUUID(a)
bu, err2 := toUUID(b)
if err1 != nil || err2 != nil {
return false, fmt.Errorf("invalid uuid.UUID value for type %s", t.String())
}
return au == bu, nil
case t.IsURI():
au, err1 := toURI(a)
bu, err2 := toURI(b)
if err1 != nil || err2 != nil {
return false, fmt.Errorf("invalid *url.URL value for type %s", t.String())
}
return au.String() == bu.String(), nil
case t.IsBinary():
ab, err1 := toBinary(a)
bb, err2 := toBinary(b)
if err1 != nil || err2 != nil {
return false, fmt.Errorf("invalid []byte value for type %s", t.String())
}
return bytes.Equal(ab, bb), nil
default:
return false, fmt.Errorf("equality not supported for type %s", t.String())
}
}
// InRange checks if a value falls within an inclusive range [min, max].
// Uses the type's comparison logic. Returns true if val is between min and max (inclusive).
//
// Example:
//
// range := ValueRange{min: uint16(10), max: uint16(100)}
// StateType_UI2.InRange(uint16(50), range) // true
func (t StateVarType) InRange(val interface{}, interval *ValueRange) (bool, error) {
if interval == nil {
return true, nil
}
cmp1, err1 := t.Cmp(val, interval.min)
cmp2, err2 := t.Cmp(val, interval.max)
if err1 != nil || err2 != nil {
err := err1
if err == nil {
err = err2
}
return false, err
}
return cmp1 >= 0 && cmp2 <= 0, nil
}

View File

@@ -0,0 +1,171 @@
package statevariables
// 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
}
}
// IsSignedInt checks if the state variable type is a signed integer type.
// The function returns true for StateType_I1, StateType_I2, StateType_I4, and
// StateType_Int, otherwise it will return false. This method is part of the
// StateVarType enumeration in statevaluetype package. It takes no parameters
// but operates on the receiver 't' of type StateVarType.
//
// The returned value is a boolean.
func (t StateVarType) IsSignedInt() bool {
switch t {
case StateType_I1, StateType_I2, StateType_I4, StateType_Int:
return true
default:
return false
}
}
// IsUnsignedInt checks if the state variable type is an unsigned integer. It
// returns a boolean indicating whether or not the current StateVarType
// represents an unsigned integer type, namely: StateType_UI1, StateType_UI2,
// and StateType_UI4.
func (t StateVarType) IsUnsignedInt() bool {
switch t {
case StateType_UI1, StateType_UI2, StateType_UI4:
return true
default:
return false
}
}
// IsFloat returns a boolean indicating whether the given state variable type
// represents a float number. If the state variable type is one of R4, R8, Number or
// Fixed14_4 it returns true; otherwise, it returns false.
func (t StateVarType) IsFloat() bool {
switch t {
case StateType_R4, StateType_R8, StateType_Number, StateType_Fixed14_4:
return true
default:
return false
}
}
// IsBool checks if a StateVarType is of type Boolean. It returns true if the
// StateVarType equals to StateType_Boolean, false otherwise.
func (t StateVarType) IsBool() bool {
return t == StateType_Boolean
}
// IsString reports whether or not the state variable type represents a string
// value.
//
// This method returns true if the StateVarType is either StateType_String or
// StateType_Char, otherwise it returns false.
func (t StateVarType) IsString() bool {
switch t {
case StateType_String, StateType_Char:
return true
default:
return false
}
}
// IsTime checks whether a given StateVarType is of time type or not. It accepts
// a StateVarType parameter 't' and returns a boolean value based on the check.
//
// The possible values for 't' are: StateType_Date, StateType_DateTime,
// StateType_DateTimeTZ, StateType_Time, StateType_TimeTZ. If 't' is any of
// these types, the function returns true; otherwise, it returns false.
func (t StateVarType) IsTime() bool {
switch t {
case StateType_Date, StateType_DateTime, StateType_DateTimeTZ,
StateType_Time, StateType_TimeTZ:
return true
default:
return false
}
}
// IsUUID reports whether the receiver represents a UUID (Universally Unique
// Identifier). The StateVarType should be of type StateType_UUID to return
// true. Otherwise, it returns false.
func (t StateVarType) IsUUID() bool {
return t == StateType_UUID
}
// IsURI checks if the given state variable type is a URI.
//
// This function returns true if and only if the receiver (StateVarType t)
// equals StateType_URI, which represents URIs in UPnP protocol. Otherwise, it
// returns false.
func (t StateVarType) IsURI() bool {
return t == StateType_URI
}
// IsBinary checks if the given StateVarType is binary type or not. It returns
// true for types StateType_BinBase64 and StateType_BinHex, otherwise it returns
// false.
func (t StateVarType) IsBinary() bool {
switch t {
case StateType_BinBase64, StateType_BinHex:
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
}
}

View File

@@ -0,0 +1,62 @@
package statevariables
import (
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
)
// toBinary tries to convert v into a []byte.
// - if v is []byte, returns it directly
// - if v is string, attempts base64 decode, then hex decode
func toBinary(v interface{}) ([]byte, error) {
switch val := v.(type) {
case []byte:
return val, nil
case string:
// Try base64 first
if data, err := base64.StdEncoding.DecodeString(val); err == nil {
return data, nil
}
// Try hex next
if data, err := hex.DecodeString(val); err == nil {
return data, nil
}
return nil, fmt.Errorf("cannot parse string as base64 or hex: %q", val)
default:
return nil, fmt.Errorf("cannot convert type %T to binary", v)
}
}
// decodeBinary decodes Base64 or Hex-encoded binary strings to byte slices
func decodeBinary(t StateVarType, val string) ([]byte, error) {
switch t {
case StateType_BinBase64:
data, err := base64.StdEncoding.DecodeString(val)
if err != nil {
return nil, fmt.Errorf("invalid base64: %v", err)
}
return data, nil
case StateType_BinHex:
// Accept even-length hex string
val = strings.TrimSpace(val)
if len(val)%2 != 0 {
return nil, fmt.Errorf("invalid hex: odd-length string")
}
data := make([]byte, len(val)/2)
_, err := hex.Decode(data, []byte(val))
if err != nil {
return nil, fmt.Errorf("invalid hex: %v", err)
}
return data, nil
default:
return nil, fmt.Errorf("decodeBinary: unsupported binary type %v", t)
}
}

View File

@@ -0,0 +1,61 @@
package statevariables
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")
}
}
// cmpBool compares two boolean values 'a' and 'b'. If they are equal it returns 0, if 'a' is false and 'b' is true it returns -1 else it returns 1.
func cmpBool(a, b bool) int {
if a == b { // if both booleans are the same, return 0
return 0
}
if !a && b { // if 'a' is false and 'b' is true, return -1
return -1
}
// otherwise, return 1
return 1 // if 'a' is true or 'a' and 'b' are not the same
}

View File

@@ -0,0 +1,95 @@
package statevariables
import (
"fmt"
"math"
"strconv"
)
// maxFloat returns the maximum value for floating point numbers given number of bits.
// If bits is neither 32 nor 64, it defaults to returning the maximum float64 value.
func maxFloat(bits int) float64 {
switch bits {
case 32:
return float64(math.MaxFloat32)
case 64:
return math.MaxFloat64
default:
return math.MaxFloat64 // fallback
}
}
// minFloat returns the minimum float value for the given number of bits.
// If bits are not recognized as either 32 or 64, it will default to the maximum
// possible float64 value.
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
}
// cmpFloat64 compares two float64 values and returns an integer indicating their relation.
// If a is less than b, it returns -1; if a is greater than b, it returns 1; otherwise, it returns 0.
func cmpFloat64(a, b float64) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}

View File

@@ -0,0 +1,143 @@
package statevariables
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
}
// cmpInt compares two integers 'a' and 'b'. If 'a' is less than 'b', it returns -1, if 'a' is greater than 'b' it returns 1, else it returns 0.
func cmpInt(a, b int64) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
// cmpUint compares two unsigned integers, a and b.
// It returns -1 if a < b.
// It returns 1 if a > b.
// It returns 0 if a == b.
func cmpUint(a, b uint64) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}

View File

@@ -0,0 +1,32 @@
package statevariables
import "fmt"
// valuesToNumericOperands takes two interface{} values, casts them to a numeric type based on a given StateVarType, and returns their float64 equivalents.
// If any error occurs during casting or conversion to float64, it is returned along with zero values for the operands.
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
}

View File

@@ -0,0 +1,37 @@
package statevariables
import (
"fmt"
)
// toString converts v to string if possible.
func toString(v interface{}) (string, error) {
switch val := v.(type) {
case string:
return val, nil
case []byte:
return string(val), nil
case fmt.Stringer:
return val.String(), nil
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", val), nil
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", val), nil
case float32, float64:
return fmt.Sprintf("%v", val), nil
case bool:
if val {
return "true", nil
}
return "false", nil
default:
return "", fmt.Errorf("cannot convert type %T to string", v)
}
}

View File

@@ -0,0 +1,104 @@
package statevariables
import (
"fmt"
"strings"
"time"
)
// toTime converts the given value to a time.Time type if possible, otherwise it returns an error.
// The supported types for conversion are: time.Time, string, int64 and float64.
func toTime(v interface{}) (time.Time, error) {
switch val := v.(type) {
case time.Time:
return val, nil
case string:
// Liste de layouts communs à UPnP / ISO-8601 / RFC
layouts := []string{
time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
time.RFC3339, // 2006-01-02T15:04:05Z07:00
"2006-01-02", // date only
"15:04:05", // time only
"15:04:05Z07:00", // time with TZ
"2006-01-02T15:04:05", // no TZ
"2006-01-02T15:04:05Z", // UTC
"2006-01-02 15:04:05", // space-separated
}
for _, layout := range layouts {
if t, err := time.Parse(layout, val); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse time string: %q", val)
case int64:
return time.Unix(val, 0), nil
case float64:
sec := int64(val)
nsec := int64((val - float64(sec)) * 1e9)
return time.Unix(sec, nsec), nil
default:
return time.Time{}, fmt.Errorf("unsupported type for time conversion: %T", v)
}
}
// parseUPnPTime parses time values using UPnP-specific formats:
// - Date: "2006-01-02"
// - Time: "15:04:05"
// - DateTime: "2006-01-02T15:04:05"
// - TimeTZ: "15:04:05-07:00"
// - DateTimeTZ: "2006-01-02T15:04:05-07:00"
func parseUPnPTime(t StateVarType, s string) (time.Time, error) {
s = strings.TrimSpace(s)
var layouts []string = nil
switch t {
case StateType_Date:
layouts = []string{"2006-01-02"}
case StateType_Time:
layouts = []string{"15:04:05"} // HH:MM:SS
case StateType_TimeTZ:
layouts = []string{"15:04:05Z07:00"} // HH:MM:SS+TZ
case StateType_DateTime:
layouts = []string{"2006-01-02T15:04:05"} // ISO8601 sans TZ
case StateType_DateTimeTZ:
layouts = []string{
"2006-01-02T15:04:05Z07:00", // full
"2006-01-02T15:04:05-0700", // fallback no colon
"2006-01-02T15:04:05Z", // Zulu
}
default:
return time.Time{}, fmt.Errorf("unsupported date/time type: %v", t)
}
for _, layout := range layouts {
if ts, err := time.Parse(layout, s); err == nil {
return ts, nil
}
}
return time.Time{}, fmt.Errorf("invalid %v value: %q", t, s)
}
// cmpTime compares two time.Times, returning -1 if the first is before the second,
// 1 if the first is after the second, and 0 if they're equal.
func cmpTime(a, b time.Time) int {
switch {
case a.Before(b):
return -1
case a.After(b):
return 1
default:
return 0
}
}

View File

@@ -0,0 +1,100 @@
package statevariables
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
}

View File

@@ -0,0 +1,25 @@
package statevariables
import (
"fmt"
"net/url"
)
// toURI converts a value to *url.URL if possible.
func toURI(v interface{}) (*url.URL, error) {
switch val := v.(type) {
case *url.URL:
// Already a URL
return val, nil
case string:
u, err := url.Parse(val)
if err != nil {
return nil, fmt.Errorf("invalid URI string %q: %v", val, err)
}
return u, nil
default:
return nil, fmt.Errorf("cannot convert type %T to URI", v)
}
}

View File

@@ -0,0 +1,32 @@
package statevariables
import (
"fmt"
"github.com/google/uuid"
)
// toUUID converts a value to uuid.UUID if possible.
func toUUID(v interface{}) (uuid.UUID, error) {
switch val := v.(type) {
case uuid.UUID:
return val, nil
case string:
u, err := uuid.Parse(val)
if err != nil {
return uuid.UUID{}, fmt.Errorf("invalid UUID string %q: %v", val, err)
}
return u, nil
case []byte:
u, err := uuid.FromBytes(val)
if err != nil {
return uuid.UUID{}, fmt.Errorf("invalid UUID bytes: %v", err)
}
return u, nil
default:
return uuid.UUID{}, fmt.Errorf("cannot convert type %T to UUID", v)
}
}

View File

@@ -0,0 +1,61 @@
package statevariables
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 cmp, err := t.Cmp(cmin, cmax); err != nil && cmp > 0 {
cmax, cmin = cmin, cmax
}
return &ValueRange{min: cmin, max: cmax}, nil
}

5
pmoupnp/events.go Normal file
View File

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

5
pmoupnp/interface.go Normal file
View File

@@ -0,0 +1,5 @@
package upnp
type Markdownable interface {
ToMarkdown() string
}

View File

@@ -0,0 +1,69 @@
package objectstore
import (
"iter"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
type ObjectIdSet map[uuid.UUID]struct{}
func (set ObjectIdSet) Contains(id uuid.UUID) bool {
_, ok := set[id]
return ok
}
func (set ObjectIdSet) Add(id uuid.UUID) {
set[id] = struct{}{}
}
func (set ObjectIdSet) Remove(id uuid.UUID) {
delete(set, id)
}
func (set ObjectIdSet) Clear() {
for k := range set {
delete(set, k)
}
}
func (set ObjectIdSet) Len() int {
return len(set)
}
func (set ObjectIdSet) All() iter.Seq[uuid.UUID] {
return func(yield func(uuid.UUID) bool) {
for uuid := range set {
if !yield(uuid) {
return
}
}
}
}
var objectstore = make(map[uuid.UUID]Object)
func RegisterObject(o Object) uuid.UUID {
id := uuid.New()
objectstore[id] = o
return id
}
func GetObject(id uuid.UUID) (Object, bool) {
o, ok := objectstore[id]
return o, ok
}
func RemoveObject(id uuid.UUID) {
if _, ok := objectstore[id]; !ok {
delete(objectstore, id)
return
}
log.Warnf("Object %s not found in objectstore", id)
}
func CountOfStoredObjects() int {
return len(objectstore)
}

View File

@@ -0,0 +1,40 @@
package objectstore
import (
"fmt"
"iter"
)
type Object interface {
Name() string
TypeID() string
}
type ObjectSet[T Object] map[string]T
func (m *ObjectSet[T]) Insert(obj T) error {
if m.Contains(obj) {
return fmt.Errorf("object %s already present in set", obj.Name())
}
(*m)[obj.Name()] = obj
return nil
}
func (m *ObjectSet[T]) InsertOrReplace(obj T) {
(*m)[obj.Name()] = obj
}
func (set *ObjectSet[T]) Contains(obj T) bool {
_, ok := (*set)[obj.Name()]
return ok
}
func (m *ObjectSet[T]) All() iter.Seq[T] {
return func(yield func(T) bool) {
for _, sv := range *m {
if !yield(sv) {
return
}
}
}
}

204
pmoupnp/server.go Normal file
View File

@@ -0,0 +1,204 @@
package upnp
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"runtime"
"sync"
"time"
"github.com/beevik/etree"
log "github.com/sirupsen/logrus"
"gargoton.petite-maison-orange.fr/eric/pmomusic/netutils"
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmoapp"
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmoconfig"
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmocover"
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmolog"
"gargoton.petite-maison-orange.fr/eric/pmomusic/ssdp"
)
type Server struct {
name string
HTTPPort int
baseURL string
Logger *log.Logger
httpSrv *http.Server
sspd *ssdp.SSDPServer
devices DeviceInstanceSet
mu sync.RWMutex
startOnce sync.Once
stopOnce sync.Once
}
func NewServer(name string, opts ...ServerOption) *Server {
config := pmoconfig.GetConfig()
baseURL := config.GetBaseURL()
httpPort := config.GetHTTPPort()
if baseURL == "" {
ip, err := netutils.GuessLocalIP()
if err != nil {
panic(fmt.Errorf("unable to determine local IP: %w", err))
}
baseURL = fmt.Sprintf("http://%s:%d", ip, httpPort)
}
s := &Server{
name: name,
HTTPPort: httpPort,
baseURL: baseURL,
Logger: log.New(),
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Server) Name() string { return s.name }
func (s *Server) TypeID() string { return "Server" }
type ServerOption func(*Server)
func WithLogger(l *log.Logger) ServerOption {
return func(s *Server) {
s.Logger = l
}
}
func (s *Server) Start() error {
s.startOnce.Do(func() {
mux := http.NewServeMux()
s.mu.RLock()
cover_cache, err := pmocover.GetCoverCache()
if err != nil {
log.Panicf("❌ Cannot initialize the Cover Cache")
}
log.Info("✅ Cover cache activated")
cover_cache.ServeMux(mux)
pmoapp.Handler(mux)
s.httpSrv = &http.Server{
Addr: fmt.Sprintf(":%d", s.HTTPPort),
Handler: mux,
}
for device := range s.devices.All() {
err := device.RegisterURLs()
if err != nil {
log.Panicf("❌ Cannot register URLs: %v", err)
}
}
s.mu.RUnlock()
go func() {
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.Logger.Printf("❌ server error: %v", err)
}
}()
log.Infof("✅ UPnP server started on %s", s.baseURL)
})
return nil
}
func (s *Server) Stop(ctx context.Context) error {
var err error
s.stopOnce.Do(func() {
if s.httpSrv != nil {
s.Logger.Println("✅ Shutting down UPNP server...")
err = s.httpSrv.Shutdown(ctx)
}
})
return err
}
func (s *Server) Run(ctx context.Context) error {
if err := s.Start(); err != nil {
return fmt.Errorf("❌ failed to start server: %w", err)
}
pmolog.LoggerWeb(ctx, s.httpSrv.Handler.(*http.ServeMux))
s.sspd = ssdp.NewSSDPServer()
if err := s.sspd.Start(ctx); err != nil {
return fmt.Errorf("❌ failed to start SSDP server: %w", err)
}
for d := range s.devices.All() {
d.RegisterSSPD()
for svc := range d.services.All() {
svc.StartNotifier(ctx, 1*time.Second)
}
}
// attente dannulation du contexte
<-ctx.Done()
// arrêt avec le même ctx ou un nouveau ctx avec timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.Stop(shutdownCtx)
}
func (s *Server) BaseURL() string { return s.baseURL }
// ServeXML prend un générateur de XML (*etree.Element)
// et renvoie la string XML avec header.
func (s *Server) XML(gen func() *etree.Element) (string, error) {
root := gen()
doc := etree.NewDocument()
doc.SetRoot(root)
doc.Indent(2)
buf := new(bytes.Buffer)
if _, err := doc.WriteTo(buf); err != nil {
return "", err
}
// Ajoute le header XML
return `<?xml version="1.0" encoding="utf-8"?>` + "\n" + buf.String(), nil
}
func (s *Server) ServeXML(gen func() *etree.Element) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
xmlStr, err := s.XML(gen)
if err != nil {
http.Error(w, "failed to generate XML", http.StatusInternalServerError)
return
}
osName := runtime.GOOS
arch := runtime.GOARCH
w.Header().Set("Server", fmt.Sprintf(
"%s/%s UPnP/1.1 PMOMusic/1.0",
osName, arch,
))
w.Header().Set("Connection", "close")
w.Header().Set("Cache-Control", "max-age=1800")
w.Header().Set("EXT", "")
w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"")
w.WriteHeader(http.StatusOK)
w.Write([]byte(xmlStr))
}
}

99
pmoupnp/service.go Normal file
View File

@@ -0,0 +1,99 @@
package upnp
import (
"fmt"
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
)
type Service struct {
name string
identifier string
version int
actions actions.ActionSet
stateTable sv.StateVariableSet
}
func NewService(name string) *Service {
svc := &Service{
name: name,
identifier: name,
version: 1,
stateTable: make(sv.StateVariableSet),
actions: make(actions.ActionSet),
}
return svc
}
func (svc *Service) Name() string {
return svc.name
}
func (svc *Service) TypeID() string {
return "Service"
}
func (svc *Service) Identifier() string {
return svc.identifier
}
func (svc *Service) SetIdentifier(id string) {
svc.identifier = id
}
func (svc *Service) SetVersion(version int) error {
if version < 1 {
return fmt.Errorf("%s", "version must be greater than or equal to 1")
}
svc.version = version
return nil
}
func (svc *Service) Version() int {
return svc.version
}
func (svc *Service) AddVariable(sv *sv.StateVariable) error {
return svc.stateTable.Insert(sv)
}
func (svc *Service) ContaintsVariable(sv *sv.StateVariable) bool {
return svc.stateTable.Contains(sv)
}
func (svc *Service) Variables() iter.Seq[*sv.StateVariable] {
return svc.stateTable.All()
}
func (svc *Service) AddAction(ac *actions.Action) error {
return svc.actions.Insert(ac)
}
func (svc *Service) NewInstance() *ServiceInstance {
instance := &ServiceInstance{
name: svc.Name(),
identifier: svc.Identifier(),
version: svc.Version(),
statevariables: make(sv.StateVarInstanceSet),
actions: make(actions.ActionInstanceSet),
subscribers: make(map[string]string),
changedBuffer: make(map[string]interface{}),
seqid: make(map[string]uint32),
}
for v := range svc.stateTable.All() {
instance.statevariables.Insert(v.NewInstance())
}
for a := range svc.actions.All() {
instance.actions.Insert(a.NewInstance())
}
return instance
}

460
pmoupnp/serviceinstance.go Normal file
View File

@@ -0,0 +1,460 @@
package upnp
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmolog"
"gargoton.petite-maison-orange.fr/eric/pmomusic/soap"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
"github.com/beevik/etree"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
type ServiceInstance struct {
name string
identifier string
version int
device *DeviceInstance
statevariables statevariables.StateVarInstanceSet
actions actions.ActionInstanceSet
subscribers map[string]string // SID → Callback URL
changedBuffer map[string]interface{}
seqid map[string]uint32
cbmu sync.Mutex
mu sync.Mutex
}
const (
MethodSubscribe = "SUBSCRIBE"
MethodUnsubscribe = "UNSUBSCRIBE"
)
func (si *ServiceInstance) Name() string {
return si.name
}
func (svc *ServiceInstance) TypeID() string {
return "ServiceInstance"
}
func (si *ServiceInstance) Identifier() string {
return si.identifier
}
func (svc *ServiceInstance) ServiceType() string {
return fmt.Sprintf("urn:schemas-upnp-org:service:%s:%d", svc.name, svc.version)
}
func (svc *ServiceInstance) ServiceId() string {
return fmt.Sprintf("urn:upnp-org:serviceId:%s", svc.identifier)
}
func (svc *ServiceInstance) BaseRoute() string {
return fmt.Sprintf("%s/service/%s", svc.device.BaseRoute(), svc.Name())
}
func (svc *ServiceInstance) ControlURL() string {
return fmt.Sprintf("%s/control", svc.BaseRoute())
}
func (svc *ServiceInstance) EventSubURL() string {
return fmt.Sprintf("%s/event", svc.BaseRoute())
}
func (svc *ServiceInstance) SCPDURL() string {
return fmt.Sprintf("%s/desc.xml", svc.BaseRoute())
}
func (svc *ServiceInstance) RegisterURLs() error {
mux, ok := svc.device.server.httpSrv.Handler.(*http.ServeMux)
if mux == nil || !ok {
return fmt.Errorf("❌ Device %s the server handler is not correctly defined", svc.Name())
}
mux.HandleFunc(
svc.SCPDURL(),
svc.device.server.ServeXML(svc.SPCDElement),
)
mux.HandleFunc(
svc.ControlURL(),
svc.ControlHandler(),
)
mux.HandleFunc(
svc.EventSubURL(),
svc.EventSubHandler(),
)
log.Infof(
"✅ Service description for %s:%s available at : %s%s",
svc.device.Name(),
svc.Name(),
svc.device.server.BaseURL(),
svc.SCPDURL(),
)
return nil
}
func (svc *ServiceInstance) USN() string {
return fmt.Sprintf("uuid:%s::urn:%s", svc.device.UDN(), svc.ServiceType())
}
func (svc *ServiceInstance) SPCDElement() *etree.Element {
elem := etree.NewElement("scpd")
elem.CreateAttr("xmlns", "urn:schemas-upnp-org:service-1-0")
spec := elem.CreateElement("specVersion")
spec.CreateElement("major").SetText("1")
spec.CreateElement("minor").SetText("0")
if len(svc.actions) > 0 {
elem.AddChild(svc.actions.ToXMLElement())
}
if len(svc.statevariables) > 0 {
elem.AddChild(svc.statevariables.ToXMLElement())
}
return elem
}
func (svc *ServiceInstance) AddSubscriber(sid, callback string) {
svc.mu.Lock()
defer svc.mu.Unlock()
if svc.subscribers == nil {
svc.subscribers = make(map[string]string)
}
svc.subscribers[sid] = callback
}
func (svc *ServiceInstance) RenewSubscriber(sid, timeout string) {
// Pour l'instant juste log, on peut étendre avec expiration
svc.mu.Lock()
defer svc.mu.Unlock()
log.Infof("Renewed SID %s for timeout %s", sid, timeout)
}
func (svc *ServiceInstance) RemoveSubscriber(sid string) {
svc.mu.Lock()
defer svc.mu.Unlock()
delete(svc.subscribers, sid)
}
// Envoi d'un événement initial (optionnel)
func (svc *ServiceInstance) SendInitialEvent(sid string) {
svc.mu.Lock()
callback := svc.subscribers[sid]
svc.mu.Unlock()
if callback == "" {
return
}
changed := make(map[string]interface{})
for name, sv := range svc.statevariables {
if sv.IsSendingEvents() { // sendEvents="yes"
changed[name] = sv.Value()
}
}
if len(changed) == 0 {
return
}
go func() {
callback = strings.TrimSpace(callback)
callback = strings.Trim(callback, "<>")
body := `<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">`
for name, val := range changed {
body += fmt.Sprintf("<e:property><%s>%v</%s></e:property>", name, val, name)
}
body += "</e:propertyset>"
req, _ := http.NewRequest("NOTIFY", callback, strings.NewReader(body))
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
req.Header.Set("NT", "upnp:event")
req.Header.Set("NTS", "upnp:propchange")
req.Header.Set("SID", sid)
req.Header.Set("SEQ", "0") // initial event
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Errorf("Failed to send initial event to %s: %v", callback, err)
return
}
defer resp.Body.Close()
log.Infof(
"✅ Initial event sent to %s, status=%s\n<details>\n\n```xml\n%s\n```\n</details>\n",
callback,
resp.Status,
pmolog.PrettyPrintXML(body),
)
}()
}
func (svc *ServiceInstance) ToXMLElement() *etree.Element {
elem := etree.NewElement("service")
st := elem.CreateElement("serviceType")
st.SetText(svc.ServiceType())
sid := elem.CreateElement("serviceId")
sid.SetText(svc.ServiceId())
spcd := elem.CreateElement("SCPDURL")
spcd.SetText(svc.SCPDURL())
ctrl := elem.CreateElement("controlURL")
ctrl.SetText(svc.ControlURL())
event := elem.CreateElement("eventSubURL")
event.SetText(svc.EventSubURL())
return elem
}
func (svc *ServiceInstance) EventToBeSent(name string, value interface{}) {
svc.cbmu.Lock()
defer svc.cbmu.Unlock()
svc.changedBuffer[name] = value
}
func (svc *ServiceInstance) nextSeq(sid string) string {
svc.mu.Lock()
defer svc.mu.Unlock()
svc.seqid[sid]++
return fmt.Sprintf("%d", svc.seqid[sid])
}
func (svc *ServiceInstance) NotifySubscribers() {
svc.cbmu.Lock()
if len(svc.subscribers) == 0 || len(svc.changedBuffer) == 0 {
svc.cbmu.Unlock()
return
}
// Copier et réinitialiser le buffer
changed := svc.changedBuffer
svc.changedBuffer = make(map[string]interface{})
svc.cbmu.Unlock()
for sid, callback := range svc.subscribers {
go func(sid, callback string, changed map[string]interface{}) {
callback = strings.TrimSpace(callback)
callback = strings.Trim(callback, "<>")
u, err := url.Parse(callback)
if err != nil {
log.Errorf("Invalid callback URL %s: %v", callback, err)
return
}
body := `<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">`
for name, val := range changed {
body += fmt.Sprintf("<e:property><%s>%v</%s></e:property>", name, val, name)
}
body += "</e:propertyset>"
req, err := http.NewRequest("NOTIFY", u.String(), strings.NewReader(body))
if err != nil {
log.Errorf("Failed to create NOTIFY request to %s: %v", callback, err)
return
}
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
req.Header.Set("NT", "upnp:event")
req.Header.Set("NTS", "upnp:propchange")
req.Header.Set("SID", sid)
req.Header.Set("SEQ", svc.nextSeq(sid))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Errorf("Failed to notify subscriber %s: %v", callback, err)
return
}
defer resp.Body.Close()
log.Infof("✅ Notified subscriber %s of changes: %v", callback, changed)
}(sid, callback, changed)
}
}
func (svc *ServiceInstance) StartNotifier(ctx context.Context, interval time.Duration) {
log.Infof("✅ Starting notifier for %s:%s every %.2f s", svc.device.Name(), svc.Name(), interval.Seconds())
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Infof("✅ Notifier stopped for %s:%s", svc.device.Name(), svc.Name())
return
case <-ticker.C:
svc.NotifySubscribers()
}
}
}()
}
func (svc *ServiceInstance) EventSubHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
log.Infof("📡 Event Subscription request for %s:%s", svc.device.Name(), svc.Name())
sid := r.Header.Get("SID")
timeout := r.Header.Get("Timeout")
callback := r.Header.Get("Callback")
switch r.Method {
case MethodSubscribe:
if sid == "" {
// Nouvelle subscription
sid = fmt.Sprintf("uuid:%s", uuid.New().String())
if callback != "" {
svc.AddSubscriber(sid, callback)
}
if timeout == "" {
timeout = "Second-1800"
}
log.Infof("🔔 New subscription: SID=%s, Callback=%s, Timeout=%s", sid, callback, timeout)
go svc.SendInitialEvent(sid) // envoyer létat initial
} else {
// Renouvellement
svc.RenewSubscriber(sid, timeout)
log.Infof("♻️ Renew subscription: SID=%s, Timeout=%s", sid, timeout)
}
w.Header().Set("SID", sid)
w.Header().Set("Timeout", timeout)
w.WriteHeader(http.StatusOK)
case MethodUnsubscribe:
if sid != "" {
svc.RemoveSubscriber(sid)
log.Infof("❌ Unsubscribe SID=%s", sid)
}
w.WriteHeader(http.StatusOK)
default:
log.Warnf("Unsupported EventSub method: %s", r.Method)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
// Debug log headers
for k, v := range r.Header {
log.Debugf("Header: %s=%v", k, v)
}
// Lire le body au besoin
body, err := io.ReadAll(r.Body)
if err == nil && len(body) > 0 {
log.Debugf("Body: %s", string(body))
}
}
}
func (svc *ServiceInstance) ControlHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
log.Infof("📡 Control request for %s:%s", svc.device.Name(), svc.Name())
body, err := io.ReadAll(r.Body)
if err != nil {
log.Errorf("❌ Failed to read body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Callback générique pour décoder chaque paramètre
decoder := func(action, param, value string) (string, interface{}, error) {
// 1⃣ Chercher si le param correspond à une StateVarInstance
sv, ok := svc.statevariables[param]
log.Debugf("Look for a variable named : %s -> %v", param, ok)
if ok {
if sv.HasParser() {
v, err := sv.ParseValue(value)
if err != nil {
return param, value, err
}
return param, v, err
}
v, err := sv.Cast(value)
if err != nil {
return param, value, err
}
return param, v, nil
}
// 2⃣ Chercher si le param correspond à une ActionInstance et appliquer un parseur associé
act, ok := svc.actions[action]
log.Debugf("Look for an action named : %s -> %v", action, ok)
if ok {
if argument, ok := act.Arguments(param); ok {
sv_name := argument.StateVariable().Name()
sv := svc.statevariables[sv_name]
v, err := sv.ParseValue(value)
log.Debugf("It corresponds to variable : %s with a parser %v", sv_name, sv.HasParser())
if err != nil {
return param, value, err
}
return param, v, nil
}
}
// 3⃣ Sinon, valeur brute
return param, value, nil
}
env, err := soap.ParseSOAPEnvelope(body)
if err != nil {
log.Errorf("❌ Failed to parse SOAP enveloppe: %v", err)
soapResp, _ := soap.BuildSOAPFault("s:Client", "Invalid Args", err.Error())
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(soapResp)
return
}
req, err := soap.ParseUPnPAction(env, decoder)
if err != nil {
log.Errorf("❌ Failed to parse SOAP Action: %v", err)
soapResp, _ := soap.BuildSOAPFault("s:Client", "Invalid Args", err.Error())
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(soapResp)
return
}
log.Info(req.ToMarkdown())
// Ici tu peux appeler l'action correspondante sur svc.actions[req.Name] et récupérer le résultat
// Exemple de réponse minimale :
resp, _ := soap.BuildUPnPResponse(svc.ServiceType(), req.Name, map[string]string{})
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(resp)
}
}

View File

@@ -0,0 +1,36 @@
package upnp
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
"github.com/beevik/etree"
)
type ServiceInstanceSet objectstore.ObjectSet[*ServiceInstance]
func (m *ServiceInstanceSet) Insert(obj *ServiceInstance) error {
return (*objectstore.ObjectSet[*ServiceInstance])(m).Insert(obj)
}
func (m *ServiceInstanceSet) InsertOrReplace(obj *ServiceInstance) {
(*objectstore.ObjectSet[*ServiceInstance])(m).InsertOrReplace(obj)
}
func (set *ServiceInstanceSet) Contains(obj *ServiceInstance) bool {
return (*objectstore.ObjectSet[*ServiceInstance])(set).Contains(obj)
}
func (m *ServiceInstanceSet) All() iter.Seq[*ServiceInstance] {
return (*objectstore.ObjectSet[*ServiceInstance])(m).All()
}
func (m *ServiceInstanceSet) ToXMLElement() *etree.Element {
elem := etree.NewElement("serviceList")
for sv := range m.All() {
elem.AddChild(sv.ToXMLElement())
}
return elem
}

25
pmoupnp/serviceset.go Normal file
View File

@@ -0,0 +1,25 @@
package upnp
import (
"iter"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
)
type ServiceSet objectstore.ObjectSet[*Service]
func (m *ServiceSet) Insert(obj *Service) error {
return (*objectstore.ObjectSet[*Service])(m).Insert(obj)
}
func (m *ServiceSet) InsertOrReplace(obj *Service) {
(*objectstore.ObjectSet[*Service])(m).InsertOrReplace(obj)
}
func (set *ServiceSet) Contains(obj *Service) bool {
return (*objectstore.ObjectSet[*Service])(set).Contains(obj)
}
func (m *ServiceSet) All() iter.Seq[*Service] {
return (*objectstore.ObjectSet[*Service])(m).All()
}

View File

@@ -0,0 +1,44 @@
package upnp
import (
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmoconfig"
log "github.com/sirupsen/logrus"
)
func (s *Server) RegisterDevice(name string, d *Device) {
s.mu.Lock()
defer s.mu.Unlock()
if s.devices == nil {
s.devices = make(DeviceInstanceSet)
}
if name == "" {
name = d.Name()
}
config := pmoconfig.GetConfig()
udn := config.GetDeviceUDN(string(d.DeviceType()), name)
instance := d.NewInstance(s, udn)
log.Infof("✅ Registering device %s", name)
err := s.devices.Insert(instance)
if err != nil {
log.Panicf("❌ Device %s is already registered", instance.Name())
}
log.Infof("✅ New device %s get UDN : %s", instance.Name(), instance.UDN())
// s.devices[name] = d
// d.mu.Lock()
// defer d.mu.Unlock()
// d.UDN = s.UDN + "-" + name
// d.Name = name
// for _, service := range d.Services.All() {
// service.DeviceName = name
// }
}