Merge pull request 'feature/soap-parser' (#2) from feature/soap-parser into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2025-09-07 12:18:26 +02:00
13 changed files with 380 additions and 42 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

9
.pmomusic.yml Normal file
View File

@@ -0,0 +1,9 @@
devices:
mediarenderer:
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
host:
http_port: "8080"

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

View File

@@ -13,6 +13,7 @@ import (
)
func main() {
log.SetLevel(log.DebugLevel) // → niveau debug
ctx, stop := signal.NotifyContext(
context.Background(),

BIN
db/chroma.sqlite3 Normal file

Binary file not shown.

89
soap/buildsoap.go Normal file
View File

@@ -0,0 +1,89 @@
package soap
import (
"bytes"
"encoding/xml"
"fmt"
)
// ----- Générateurs -----
// BuildUPnPResponse construit une réponse SOAP avec <ActionNameResponse>
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(`<u:%sResponse xmlns:u="%s">`, action, serviceURN))
for k, v := range values {
buf.WriteString(fmt.Sprintf("<%s>%s</%s>", k, xmlEscape(v), k))
}
buf.WriteString(fmt.Sprintf(`</u:%sResponse>`, action))
return buf.Bytes()
}
func buildFault(code, description, detail string) []byte {
return []byte(fmt.Sprintf(`
<Fault>
<faultcode>%s</faultcode>
<faultstring>%s</faultstring>
<detail>%s</detail>
</Fault>`, 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(`<?xml version="1.0" encoding="utf-8"?>`)
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()
}

View File

@@ -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 laction et ses arguments à partir dun 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)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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.
//

5
upnp/interface.go Normal file
View File

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

View File

@@ -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(`<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body/>
</s:Envelope>`))
_, _ = w.Write(resp)
}
}