Changes to be committed:

modified:   cmd/pmomusic/main.go
	new file:   internal/netutils/ip_detect.go
	new file:   internal/netutils/list_all_ip.go
	modified:   internal/soap/soap.go
	modified:   internal/ssdp/responder.go
	modified:   internal/ssdp/ssdp.go
	modified:   internal/upnp/http.go
	new file:   internal/upnp/server.go
	new file:   internal/upnp/xml/AVTransport.xml
	new file:   internal/upnp/xml/ConnectionManager.xml
	new file:   internal/upnp/xml/RenderingControl.xml
This commit is contained in:
2025-06-08 17:41:52 +02:00
parent c80f49f615
commit 2e80eb85d2
11 changed files with 420 additions and 40 deletions

View File

@@ -3,12 +3,18 @@ package main
import ( import (
"log" "log"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/netutils"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp" "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp" "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
) )
func main() { func main() {
localIP := ssdp.GetLocalIP() localIP, err := netutils.GuessLocalIP()
if err != nil {
log.Fatalf("Could not guess local IP: %v", err)
}
location := "http://" + localIP + ":1400/description.xml" location := "http://" + localIP + ":1400/description.xml"
usn := "uuid:pmomusic-renderer-001" usn := "uuid:pmomusic-renderer-001"
@@ -18,5 +24,5 @@ func main() {
// Lance le serveur HTTP // Lance le serveur HTTP
log.Println("Starting HTTP server at:", location) log.Println("Starting HTTP server at:", location)
upnp.StartHTTPServer(usn) upnp.StartHTTPServer(usn, localIP, 1400)
} }

View File

@@ -0,0 +1,92 @@
package netutils
import (
"errors"
"net"
"sort"
"strings"
)
// GuessLocalIP returns the best-guess local IP address usable for UPnP location,
// in order of preference: eth0 > en* > wl* > any private, non-loopback IP.
func GuessLocalIP() (string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}
type scoredIP struct {
ip net.IP
score int
}
var candidates []scoredIP
for _, iface := range ifaces {
// Skip interfaces that are down or loopback
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
// Skip non-IPv4 or non-private addresses
if ip == nil || ip.IsLoopback() || ip.To4() == nil || !isPrivateIPv4(ip) {
continue
}
score := scoreInterfaceName(iface.Name)
candidates = append(candidates, scoredIP{ip: ip, score: score})
}
}
if len(candidates) == 0 {
return "", errors.New("no suitable local IP found")
}
// Prefer interfaces with higher score
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
return candidates[0].ip.String(), nil
}
func isPrivateIPv4(ip net.IP) bool {
private := []string{
"10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.2", "192.168.",
}
ipStr := ip.String()
for _, p := range private {
if strings.HasPrefix(ipStr, p) {
return true
}
}
return false
}
func scoreInterfaceName(name string) int {
switch {
case name == "eth0":
return 100
case strings.HasPrefix(name, "en"):
return 80
case name == "wlan0" || strings.HasPrefix(name, "wl"):
return 60
default:
return 10
}
}

View File

@@ -0,0 +1,49 @@
package netutils
import (
"net"
)
// ListAllIPs returns a map of interface names to their associated IPv4 addresses.
func ListAllIPs() map[string][]string {
result := make(map[string][]string)
ifaces, err := net.Interfaces()
if err != nil {
result["error"] = []string{err.Error()}
return result
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue // Ignore down interfaces
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
var ips []string
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.To4() == nil || ip.IsLoopback() {
continue
}
ips = append(ips, ip.String())
}
if len(ips) > 0 {
result[iface.Name] = ips
}
}
return result
}

View File

@@ -1,18 +1,69 @@
package soap package soap
import ( import (
"bytes"
"encoding/xml"
"io" "io"
"log" "log"
"net/http" "net/http"
) )
func HandleSOAP(w http.ResponseWriter, r *http.Request) { func HandleSOAP(w http.ResponseWriter, r *http.Request) {
action := r.Header.Get("SOAPACTION") body, err := io.ReadAll(r.Body)
body, _ := io.ReadAll(r.Body) if err != nil {
log.Println("Failed to read SOAP request:", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
defer r.Body.Close()
log.Println("----------") log.Println("Received SOAP request:")
log.Println("SOAPAction:", action) log.Println(string(body))
log.Println("Body:\n", string(body))
// Extract action name from the SOAP body
action := extractSOAPAction(body)
log.Printf("SOAP Action: %s\n", action)
// Send dummy response
w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(dummySOAPResponse(action)))
}
func extractSOAPAction(body []byte) string {
type Envelope struct {
Body struct {
XMLName xml.Name
} `xml:"Body"`
}
var env Envelope
if err := xml.Unmarshal(body, &env); err != nil {
log.Println("SOAP parse error:", err)
return "UnknownAction"
}
decoder := xml.NewDecoder(bytes.NewReader(body))
for {
tok, err := decoder.Token()
if err != nil {
break
}
if se, ok := tok.(xml.StartElement); ok && se.Name.Local != "Envelope" && se.Name.Local != "Body" {
return se.Name.Local
}
}
return "UnknownAction"
}
func dummySOAPResponse(action string) string {
// You can specialize per action if needed.
return `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:` + action + `Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
</u:` + action + `Response>
</s:Body>
</s:Envelope>`
} }

View File

@@ -43,14 +43,19 @@ func StartSSDPResponder(usn, location string) {
st := extractHeader(data, "ST") st := extractHeader(data, "ST")
log.Printf("🔎 M-SEARCH from %s, ST=%s\n", src.String(), st) log.Printf("🔎 M-SEARCH from %s, ST=%s\n", src.String(), st)
if st == "ssdp:all" || st == serviceType { if st == "ssdp:all" ||
go sendSSDPResponse(src, usn, location) st == serviceType ||
strings.HasPrefix(st, "urn:schemas-upnp-org:service:") ||
strings.HasPrefix(st, "urn:av-openhome-org:service:") ||
strings.HasPrefix(st, "urn:bubblesoftapps-com:service:") {
go sendSSDPResponse(src, usn, location, st)
} }
} }
} }
} }
func sendSSDPResponse(dst *net.UDPAddr, usn, location string) { func sendSSDPResponse(dst *net.UDPAddr, usn, location, st string) {
resp := fmt.Sprintf( resp := fmt.Sprintf(
"HTTP/1.1 200 OK\r\n"+ "HTTP/1.1 200 OK\r\n"+
"CACHE-CONTROL: max-age=1800\r\n"+ "CACHE-CONTROL: max-age=1800\r\n"+
@@ -63,11 +68,12 @@ func sendSSDPResponse(dst *net.UDPAddr, usn, location string) {
"\r\n", "\r\n",
time.Now().Format(time.RFC1123), time.Now().Format(time.RFC1123),
location, location,
serviceType, st,
usn, usn,
serviceType, st,
) )
// envoie UDP
conn, err := net.DialUDP("udp4", nil, dst) conn, err := net.DialUDP("udp4", nil, dst)
if err != nil { if err != nil {
log.Println("Failed to dial UDP to respond:", err) log.Println("Failed to dial UDP to respond:", err)

View File

@@ -2,7 +2,6 @@ package ssdp
import ( import (
"log" "log"
"net"
"time" "time"
"github.com/koron/go-ssdp" "github.com/koron/go-ssdp"
@@ -32,12 +31,3 @@ func AnnounceRenderer(usn, location string) {
// Un jour on voudra faire ça à l'arrêt : // Un jour on voudra faire ça à l'arrêt :
// adv.Close() // envoie le ssdp:byebye // adv.Close() // envoie le ssdp:byebye
} }
func GetLocalIP() string {
conn, err := net.Dial("udp", "239.255.255.250:1900")
if err != nil {
return "127.0.0.1"
}
defer conn.Close()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP.String()
}

View File

@@ -2,30 +2,16 @@ package upnp
import ( import (
"fmt" "fmt"
"log"
"net/http"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap"
) )
func StartHTTPServer(usn string) { func generateDeviceDescription(usn, ip string, port uint) string {
http.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.Write([]byte(generateDeviceDescription(usn)))
})
http.HandleFunc("/upnp/control/AVTransport", soap.HandleSOAP)
log.Fatal(http.ListenAndServe(":1400", nil))
}
func generateDeviceDescription(usn string) string {
return fmt.Sprintf(`<?xml version="1.0"?> return fmt.Sprintf(`<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0"> <root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion> <specVersion>
<major>1</major> <major>1</major>
<minor>0</minor> <minor>0</minor>
</specVersion> </specVersion>
<URLBase>http://%s:%d/</URLBase>
<device> <device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType> <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>pmomusic Fake Renderer</friendlyName> <friendlyName>pmomusic Fake Renderer</friendlyName>
@@ -36,7 +22,7 @@ func generateDeviceDescription(usn string) string {
<modelNumber>1.0</modelNumber> <modelNumber>1.0</modelNumber>
<modelURL>http://example.com/model</modelURL> <modelURL>http://example.com/model</modelURL>
<UDN>%s</UDN> <UDN>%s</UDN>
<presentationURL>http://%s</presentationURL> <presentationURL>http://%s:%d</presentationURL>
<serviceList> <serviceList>
<service> <service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType> <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
@@ -61,5 +47,5 @@ func generateDeviceDescription(usn string) string {
</service> </service>
</serviceList> </serviceList>
</device> </device>
</root>`, usn, "127.0.0.1:1400") // à adapter à ton IP:port réel </root>`, ip, port, usn, ip, port)
} }

45
internal/upnp/server.go Normal file
View File

@@ -0,0 +1,45 @@
package upnp
import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap"
)
//go:embed xml/*.xml
var embeddedXML embed.FS
// ServeStaticXML mounts handlers for SCPD XML files.
func ServeStaticXML(mux *http.ServeMux) {
subFS, err := fs.Sub(embeddedXML, "xml")
if err != nil {
panic("failed to create sub FS: " + err.Error())
}
mux.Handle("/scpd/", http.StripPrefix("/scpd/", http.FileServer(http.FS(subFS))))
}
func StartHTTPServer(usn, ip string, port uint) {
mux := http.NewServeMux()
// Device description
mux.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.Write([]byte(generateDeviceDescription(usn, ip, port)))
})
// SOAP control endpoints
mux.HandleFunc("/upnp/control/AVTransport", soap.HandleSOAP)
mux.HandleFunc("/upnp/control/RenderingControl", soap.HandleSOAP)
mux.HandleFunc("/upnp/control/ConnectionManager", soap.HandleSOAP)
// Serve static SCPD XML files
ServeStaticXML(mux)
addr := fmt.Sprintf("%s:%d", ip, port)
log.Printf("Serving UPnP fake renderer at http://%s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}

View File

@@ -0,0 +1,81 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>SetAVTransportURI</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
<argument>
<name>CurrentURI</name>
<direction>in</direction>
<relatedStateVariable>AVTransportURI</relatedStateVariable>
</argument>
<argument>
<name>CurrentURIMetaData</name>
<direction>in</direction>
<relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Play</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
<argument>
<name>Speed</name>
<direction>in</direction>
<relatedStateVariable>TransportPlaySpeed</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Stop</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_InstanceID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>AVTransportURI</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>AVTransportURIMetaData</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TransportPlaySpeed</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>1</allowedValue>
</allowedValueList>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion><major>1</major><minor>0</minor></specVersion>
<actionList>
<action>
<name>GetProtocolInfo</name>
<argumentList>
<argument>
<name>Source</name>
<direction>out</direction>
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>Sink</name>
<direction>out</direction>
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>SourceProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SinkProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion><major>1</major><minor>0</minor></specVersion>
<actionList>
<action>
<name>SetVolume</name>
<argumentList>
<argument>
<name>InstanceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
</argument>
<argument>
<name>Channel</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
</argument>
<argument>
<name>DesiredVolume</name>
<direction>in</direction>
<relatedStateVariable>Volume</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_InstanceID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Channel</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Master</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>Volume</name>
<dataType>ui2</dataType>
</stateVariable>
</serviceStateTable>
</scpd>