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