diff --git a/pmolog/prettyxml.go b/pmolog/prettyxml.go
new file mode 100644
index 00000000..1c7ced96
--- /dev/null
+++ b/pmolog/prettyxml.go
@@ -0,0 +1,24 @@
+package pmolog
+
+import (
+ "bytes"
+ "encoding/xml"
+)
+
+func PrettyPrintXML(raw string) string {
+ var out bytes.Buffer
+ dec := xml.NewDecoder(bytes.NewReader([]byte(raw)))
+ enc := xml.NewEncoder(&out)
+ enc.Indent("", " ")
+ for {
+ t, err := dec.Token()
+ if err != nil {
+ break
+ }
+ if err := enc.EncodeToken(t); err != nil {
+ break
+ }
+ }
+ enc.Flush()
+ return out.String()
+}
diff --git a/soap/parseSoap.go b/soap/parseSoap.go
index f8cc4d65..66b4fc6b 100644
--- a/soap/parseSoap.go
+++ b/soap/parseSoap.go
@@ -48,24 +48,6 @@ type Fault struct {
// ----- Utils -----
-func prettyPrintXML(raw string) string {
- var out bytes.Buffer
- dec := xml.NewDecoder(bytes.NewReader([]byte(raw)))
- enc := xml.NewEncoder(&out)
- enc.Indent("", " ")
- for {
- t, err := dec.Token()
- if err != nil {
- break
- }
- if err := enc.EncodeToken(t); err != nil {
- break
- }
- }
- enc.Flush()
- return out.String()
-}
-
// ----- Parseurs -----
func ParseSOAPEnvelope(body []byte) (*Envelope, error) {
diff --git a/upnp/devices/services/statevariables/statevalueinstance.go b/upnp/devices/services/statevariables/statevalueinstance.go
index 7c06b518..2b465211 100644
--- a/upnp/devices/services/statevariables/statevalueinstance.go
+++ b/upnp/devices/services/statevariables/statevalueinstance.go
@@ -12,8 +12,14 @@ import (
"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
@@ -27,12 +33,12 @@ type StateVarInstance struct {
sendEvents bool
parse StringValueParser
marshal ValueSerializer
-
- value interface{}
- previousValue interface{}
- lastChange time.Time
- lastEvent time.Time
- mu sync.RWMutex
+ service notifiable
+ value interface{}
+ previousValue interface{}
+ lastChange time.Time
+ lastEvent time.Time
+ mu sync.RWMutex
}
func (instance *StateVarInstance) Name() string {
@@ -102,13 +108,11 @@ func (instance *StateVarInstance) ParseValue(value string) (interface{}, error)
// IsValueInRange checks if a value falls within the defined range.
// Always returns true if no range is set.
-
// Parameters:
-
-// value: Value to check
-
+//
+// 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)
@@ -186,27 +190,73 @@ func (instance *StateVarInstance) SetValue(val interface{}) error {
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() {
- instance.mu.Lock()
- defer instance.mu.Unlock()
+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 {
- for _, condition := range instance.model.eventConditions {
- if !condition(instance) {
- return false
+ 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 true
+ return false
}
func (sv *StateVarInstance) GenerateEvent() *etree.Element {
diff --git a/upnp/server.go b/upnp/server.go
index 8419401c..32b70f88 100644
--- a/upnp/server.go
+++ b/upnp/server.go
@@ -131,8 +131,11 @@ func (s *Server) Run(ctx context.Context) error {
}
for d := range s.devices.All() {
- log.Infof("coucou from %s", d.Name())
d.RegisterSSPD()
+
+ for svc := range d.services.All() {
+ svc.StartNotifier(ctx, 1*time.Second)
+ }
}
// attente d’annulation du contexte
diff --git a/upnp/service.go b/upnp/service.go
index f4e148b8..21dcb5cb 100644
--- a/upnp/service.go
+++ b/upnp/service.go
@@ -81,6 +81,10 @@ func (svc *Service) NewInstance() *ServiceInstance {
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() {
diff --git a/upnp/serviceinstance.go b/upnp/serviceinstance.go
index a3ffbf47..410e384f 100644
--- a/upnp/serviceinstance.go
+++ b/upnp/serviceinstance.go
@@ -1,14 +1,21 @@
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"
)
@@ -20,8 +27,18 @@ type ServiceInstance struct {
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
}
@@ -115,6 +132,81 @@ func (svc *ServiceInstance) SPCDElement() *etree.Element {
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 := ``
+ for name, val := range changed {
+ body += fmt.Sprintf("<%s>%v%s>", name, val, name)
+ }
+ body += ""
+
+ 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\n\n```xml\n%s\n```\n \n",
+ callback,
+ resp.Status,
+ pmolog.PrettyPrintXML(body),
+ )
+ }()
+}
+
func (svc *ServiceInstance) ToXMLElement() *etree.Element {
elem := etree.NewElement("service")
@@ -136,11 +228,146 @@ func (svc *ServiceInstance) ToXMLElement() *etree.Element {
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 := ``
+ for name, val := range changed {
+ body += fmt.Sprintf("<%s>%v%s>", name, val, name)
+ }
+ body += ""
+
+ 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) {
- w.WriteHeader(http.StatusOK)
- log.Infof("Event Subscription handler for %s:%s", svc.device.Name(), svc.Name())
- // corps vide volontairement
+ 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))
+ }
}
}