From 21caf3cac247f5c3c4a75db35efdb8b2260fcc9b Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Sep 2025 08:47:54 +0200 Subject: [PATCH 1/2] Change ID: xnmuwquy This commit contains the following changes: A .DS_Store A .pmomusic.yml A .vscode/settings.json A db/chroma.sqlite3 --- .DS_Store | Bin 0 -> 6148 bytes .vscode/settings.json | 3 +++ db/chroma.sqlite3 | Bin 0 -> 163840 bytes 3 files changed, 3 insertions(+) create mode 100644 .DS_Store create mode 100644 .vscode/settings.json create mode 100644 db/chroma.sqlite3 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7c893150dc3a22bb56b525a022fb0aae7d5ea199 GIT binary patch literal 6148 zcmeHKyG{c^3>?E1C()#&++W}iR#Er@et^eOAVmZb=&$0t_%y~35YYuu(V%f;&#uq2 ztDE8+1F-e$bOEdYjC4o5d03i1cVE~^WgIQec*Y}cu$}gI+eP*9gmXtckoSUneBI`ggSdf}XybXeTXbLwWR3B}@e=3A7* zdZMBfkOGGaoaT1r{r{5w!~B0p(oPCUfq$id4V$aYny*y7b@6iEYa9KV?ltdpH?D)i n5bc;4?U);H$LCR$b;Ee2xHD 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 Date: Sun, 7 Sep 2025 11:45:43 +0200 Subject: [PATCH 2/2] Avec un parser soap un peu plus modulaire et un debut de ControlHandler sur les services qui fait des choses --- cmd/pmomusic/main.go | 1 + soap/buildsoap.go | 89 ++++++++ soap/parseSoap.go | 191 +++++++++++++++--- .../AVTransport/sv_AVTransportURIMetaData.go | 17 +- .../services/actions/actioninstance.go | 5 + .../statevariables/statevalueinstance.go | 12 ++ .../services/statevariables/statevariable.go | 4 + upnp/interface.go | 5 + upnp/serviceinstance.go | 86 +++++++- 9 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 soap/buildsoap.go create mode 100644 upnp/interface.go diff --git a/cmd/pmomusic/main.go b/cmd/pmomusic/main.go index f19c0b9e..723eb538 100644 --- a/cmd/pmomusic/main.go +++ b/cmd/pmomusic/main.go @@ -13,6 +13,7 @@ import ( ) func main() { + log.SetLevel(log.DebugLevel) // → niveau debug ctx, stop := signal.NotifyContext( context.Background(), diff --git a/soap/buildsoap.go b/soap/buildsoap.go new file mode 100644 index 00000000..18b78f3e --- /dev/null +++ b/soap/buildsoap.go @@ -0,0 +1,89 @@ +package soap + +import ( + "bytes" + "encoding/xml" + "fmt" +) + +// ----- Générateurs ----- + +// BuildUPnPResponse construit une réponse SOAP avec +func BuildUPnPResponse(serviceURN, action string, values map[string]string) ([]byte, error) { + env := &Envelope{ + XMLName: xml.Name{Local: "s:Envelope"}, + Body: Body{ + Content: buildActionResponse(serviceURN, action, values), + }, + } + + return marshalSOAP(env) +} + +// BuildSOAPFault construit un Fault SOAP standard +func BuildSOAPFault(code, description, detail string) ([]byte, error) { + env := &Envelope{ + XMLName: xml.Name{Local: "s:Envelope"}, + Body: Body{ + Content: buildFault(code, description, detail), + }, + } + + return marshalSOAP(env) +} + +// ----- Internes ----- + +func buildActionResponse(serviceURN, action string, values map[string]string) []byte { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf(``, action, serviceURN)) + for k, v := range values { + buf.WriteString(fmt.Sprintf("<%s>%s", k, xmlEscape(v), k)) + } + buf.WriteString(fmt.Sprintf(``, action)) + return buf.Bytes() +} + +func buildFault(code, description, detail string) []byte { + return []byte(fmt.Sprintf(` + + %s + %s + %s +`, xmlEscape(code), xmlEscape(description), xmlEscape(detail))) +} + +func marshalSOAP(env *Envelope) ([]byte, error) { + type soapEnvelope struct { + XMLName xml.Name `xml:"s:Envelope"` + SoapNS string `xml:"xmlns:s,attr"` + EncNS string `xml:"s:encodingStyle,attr"` + Body struct { + XMLName xml.Name `xml:"s:Body"` + Content string `xml:",innerxml"` + } + } + + tmp := soapEnvelope{ + SoapNS: "http://schemas.xmlsoap.org/soap/envelope/", + EncNS: "http://schemas.xmlsoap.org/soap/encoding/", + } + tmp.Body.Content = string(env.Body.Content) + + var buf bytes.Buffer + buf.WriteString(``) + enc := xml.NewEncoder(&buf) + enc.Indent("", " ") + if err := enc.Encode(tmp); err != nil { + return nil, err + } + enc.Flush() + return buf.Bytes(), nil +} + +// xmlEscape échappe manuellement les caractères dangereux +func xmlEscape(s string) string { + var buf bytes.Buffer + xml.EscapeText(&buf, []byte(s)) + return buf.String() +} diff --git a/soap/parseSoap.go b/soap/parseSoap.go index 795ed13d..f8cc4d65 100644 --- a/soap/parseSoap.go +++ b/soap/parseSoap.go @@ -3,84 +3,211 @@ package soap import ( "bytes" "encoding/xml" + "fmt" "io" - "gargoton.petite-maison-orange.fr/eric/pmomusic/didl" log "github.com/sirupsen/logrus" ) +// ----- SOAP envelope ----- + type Envelope struct { - XMLName xml.Name `xml:"Envelope"` + XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"` + Header *Header `xml:"Header"` Body Body `xml:"Body"` } -type Body struct { - Content []byte `xml:",innerxml"` // <- capture tout le contenu du Body sous forme de XML brut +type Header struct { + Content []byte `xml:",innerxml"` } +type Body struct { + Content []byte `xml:",innerxml"` +} + +// ----- UPnP request/response ----- + +type ActionRequest struct { + Name string + Args map[string]interface{} + RawXML []byte +} + +type ActionResponse struct { + Name string + Values map[string]string + RawXML []byte +} + +type Fault struct { + Code string + Description string + Detail string + RawXML []byte +} + +// ----- Utils ----- + func prettyPrintXML(raw string) string { var out bytes.Buffer - decoder := xml.NewDecoder(bytes.NewReader([]byte(raw))) - encoder := xml.NewEncoder(&out) - encoder.Indent("", " ") // définit l'indentation + dec := xml.NewDecoder(bytes.NewReader([]byte(raw))) + enc := xml.NewEncoder(&out) + enc.Indent("", " ") for { - t, err := decoder.Token() + t, err := dec.Token() if err != nil { break } - if err := encoder.EncodeToken(t); err != nil { + if err := enc.EncodeToken(t); err != nil { break } } - encoder.Flush() + enc.Flush() return out.String() } -func ParseSOAPGeneric(body []byte) { +// ----- Parseurs ----- + +func ParseSOAPEnvelope(body []byte) (*Envelope, error) { var env Envelope if err := xml.Unmarshal(body, &env); err != nil { - log.Warnf("❌ Failed to unmarshal SOAP Envelope: %v", err) - return + return nil, fmt.Errorf("unmarshal SOAP Envelope: %w", err) } + return &env, nil +} - decoder := xml.NewDecoder(bytes.NewReader(env.Body.Content)) +// ParamDecoder permet de transformer les valeurs des paramètres et éventuellement renommer le paramètre +type ParamDecoder func(action, param, value string) (newParam string, out interface{}, err error) + +// ParseUPnPAction extrait l’action et ses arguments à partir d’un Body SOAP. +// Si decoder != nil, il est appelé pour chaque paramètre. +func ParseUPnPAction(env *Envelope, decoder ParamDecoder) (*ActionRequest, error) { + dec := xml.NewDecoder(bytes.NewReader(env.Body.Content)) var currentAction string args := make(map[string]interface{}) for { - tok, err := decoder.Token() + tok, err := dec.Token() if err != nil { - if err != io.EOF { - log.Warnf("❌ SOAP parse error: %v", err) + if err == io.EOF { + break } - break + return nil, fmt.Errorf("SOAP parse error: %w", err) } switch t := tok.(type) { case xml.StartElement: if currentAction == "" { - currentAction = t.Name.Local // nom de l'action + currentAction = t.Name.Local } else { var value string - decoder.DecodeElement(&value, &t) - - var ival interface{} - ival, err = didl.Parse(value) - - if err == nil { - ival = ival.(*didl.DIDLLite).ToMarkdown() - } else { - ival = prettyPrintXML(value) + if err := dec.DecodeElement(&value, &t); err != nil { + return nil, fmt.Errorf("decode param %s: %w", t.Name.Local, err) } - args[t.Name.Local] = ival + paramName := t.Name.Local + var paramValue interface{} = value + if decoder != nil { + if newName, out, err := decoder(currentAction, paramName, value); err == nil { + paramName = newName + paramValue = out + } + } + + args[paramName] = paramValue } } } - log.Infof("📡 SOAP Action: %s", currentAction) - for k, v := range args { - log.Infof(" %s = %v", k, v) + return &ActionRequest{ + Name: currentAction, + Args: args, + RawXML: env.Body.Content, + }, nil +} + +// Response (renderer -> contrôleur) +func ParseUPnPResponse(env *Envelope) (*ActionResponse, *Fault, error) { + dec := xml.NewDecoder(bytes.NewReader(env.Body.Content)) + var respName string + values := make(map[string]string) + + for { + tok, err := dec.Token() + if err != nil { + if err == io.EOF { + break + } + return nil, nil, fmt.Errorf("SOAP parse error: %w", err) + } + + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "Fault" { + // Parse fautes SOAP + var f struct { + Code string `xml:"faultcode"` + Desc string `xml:"faultstring"` + Detail string `xml:"detail"` + } + if err := dec.DecodeElement(&f, &t); err != nil { + return nil, nil, fmt.Errorf("decode Fault: %w", err) + } + return nil, &Fault{ + Code: f.Code, + Description: f.Desc, + Detail: f.Detail, + RawXML: env.Body.Content, + }, nil + } + + if respName == "" { + respName = t.Name.Local + } else { + var value string + if err := dec.DecodeElement(&value, &t); err != nil { + return nil, nil, fmt.Errorf("decode response param %s: %w", t.Name.Local, err) + } + values[t.Name.Local] = value + } + } + } + + if respName == "" { + return nil, nil, fmt.Errorf("no response or fault in SOAP body") + } + + return &ActionResponse{respName, values, env.Body.Content}, nil, nil +} + +// ----- Exemple ----- + +func ParseSOAPGeneric(body []byte, decoder ParamDecoder) { + env, err := ParseSOAPEnvelope(body) + if err != nil { + log.Warnf("❌ %v", err) + return + } + + // Essayer d'abord comme requête + if req, err := ParseUPnPAction(env, decoder); err == nil && req.Name != "" { + log.Infof("📡 SOAP Request Action: %s", req.Name) + for k, v := range req.Args { + log.Infof(" %s = %v", k, v) + } + return + } + + // Sinon comme réponse + if resp, fault, err := ParseUPnPResponse(env); err == nil { + if resp != nil { + log.Infof("📡 SOAP Response: %s", resp.Name) + for k, v := range resp.Values { + log.Infof(" %s = %v", k, v) + } + } else if fault != nil { + log.Warnf("❌ SOAP Fault: %s - %s (detail: %s)", fault.Code, fault.Description, fault.Detail) + } } } diff --git a/upnp/devices/mediarenderer/AVTransport/sv_AVTransportURIMetaData.go b/upnp/devices/mediarenderer/AVTransport/sv_AVTransportURIMetaData.go index 22f2197e..f45ff1c2 100644 --- a/upnp/devices/mediarenderer/AVTransport/sv_AVTransportURIMetaData.go +++ b/upnp/devices/mediarenderer/AVTransport/sv_AVTransportURIMetaData.go @@ -1,10 +1,25 @@ package avtransport -import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables" +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.Warnf("[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 }() diff --git a/upnp/devices/services/actions/actioninstance.go b/upnp/devices/services/actions/actioninstance.go index b16b6e99..c5b548f0 100644 --- a/upnp/devices/services/actions/actioninstance.go +++ b/upnp/devices/services/actions/actioninstance.go @@ -16,6 +16,11 @@ 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") diff --git a/upnp/devices/services/statevariables/statevalueinstance.go b/upnp/devices/services/statevariables/statevalueinstance.go index 811213a3..7c06b518 100644 --- a/upnp/devices/services/statevariables/statevalueinstance.go +++ b/upnp/devices/services/statevariables/statevalueinstance.go @@ -3,6 +3,7 @@ package statevariables import ( "encoding/base64" "encoding/hex" + "errors" "fmt" "net/url" "reflect" @@ -88,6 +89,17 @@ 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. diff --git a/upnp/devices/services/statevariables/statevariable.go b/upnp/devices/services/statevariables/statevariable.go index 2fa37b34..90508f80 100644 --- a/upnp/devices/services/statevariables/statevariable.go +++ b/upnp/devices/services/statevariables/statevariable.go @@ -147,6 +147,10 @@ func (state *StateVariable) Minimum() interface{} { 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. // diff --git a/upnp/interface.go b/upnp/interface.go new file mode 100644 index 00000000..aac55023 --- /dev/null +++ b/upnp/interface.go @@ -0,0 +1,5 @@ +package upnp + +type Markdownable interface { + ToMarkdown() string +} diff --git a/upnp/serviceinstance.go b/upnp/serviceinstance.go index dea68472..a62c6f4a 100644 --- a/upnp/serviceinstance.go +++ b/upnp/serviceinstance.go @@ -147,9 +147,6 @@ func (svc *ServiceInstance) EventSubHandler() func(w http.ResponseWriter, r *htt 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()) - log.Infof("➡️ Method: %s URL: %s", r.Method, r.URL.Path) - log.Infof("Header SOAPACTION: %s", r.Header.Get("SOAPACTION")) - log.Infof("Header Content-Type: %s", r.Header.Get("Content-Type")) body, err := io.ReadAll(r.Body) if err != nil { @@ -158,14 +155,85 @@ func (svc *ServiceInstance) ControlHandler() func(w http.ResponseWriter, r *http return } - soap.ParseSOAPGeneric(body) + // 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 - // Réponse minimale SOAP + sv, ok := svc.statevariables[param] + log.Warnf("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.Infof("➡️ SOAP Action: %s", req.Name) + for k, v := range req.Args { + if mi, ok := v.(Markdownable); ok { + v = mi.ToMarkdown() + } + log.Infof(" %s:%s = %v", req.Name, k, v) + } + + // 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([]byte(` - - -`)) + _, _ = w.Write(resp) } }