diff --git a/cmd/pmomusic/main.go b/cmd/pmomusic/main.go
index 7cfe12a6..c1e1e805 100644
--- a/cmd/pmomusic/main.go
+++ b/cmd/pmomusic/main.go
@@ -3,12 +3,18 @@ package main
import (
"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/upnp"
)
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"
usn := "uuid:pmomusic-renderer-001"
@@ -18,5 +24,5 @@ func main() {
// Lance le serveur HTTP
log.Println("Starting HTTP server at:", location)
- upnp.StartHTTPServer(usn)
+ upnp.StartHTTPServer(usn, localIP, 1400)
}
diff --git a/internal/netutils/ip_detect.go b/internal/netutils/ip_detect.go
new file mode 100644
index 00000000..7c89067e
--- /dev/null
+++ b/internal/netutils/ip_detect.go
@@ -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
+ }
+}
diff --git a/internal/netutils/list_all_ip.go b/internal/netutils/list_all_ip.go
new file mode 100644
index 00000000..19240745
--- /dev/null
+++ b/internal/netutils/list_all_ip.go
@@ -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
+}
diff --git a/internal/soap/soap.go b/internal/soap/soap.go
index d7f2d016..ca536cd1 100644
--- a/internal/soap/soap.go
+++ b/internal/soap/soap.go
@@ -1,18 +1,69 @@
package soap
import (
+ "bytes"
+ "encoding/xml"
"io"
"log"
"net/http"
)
func HandleSOAP(w http.ResponseWriter, r *http.Request) {
- action := r.Header.Get("SOAPACTION")
- body, _ := io.ReadAll(r.Body)
+ body, err := 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("SOAPAction:", action)
- log.Println("Body:\n", string(body))
+ log.Println("Received SOAP request:")
+ log.Println(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.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 `
+
+
+
+
+
+`
}
diff --git a/internal/ssdp/responder.go b/internal/ssdp/responder.go
index 2dece7ec..19064f48 100644
--- a/internal/ssdp/responder.go
+++ b/internal/ssdp/responder.go
@@ -43,14 +43,19 @@ func StartSSDPResponder(usn, location string) {
st := extractHeader(data, "ST")
log.Printf("🔎 M-SEARCH from %s, ST=%s\n", src.String(), st)
- if st == "ssdp:all" || st == serviceType {
- go sendSSDPResponse(src, usn, location)
+ if st == "ssdp:all" ||
+ 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(
"HTTP/1.1 200 OK\r\n"+
"CACHE-CONTROL: max-age=1800\r\n"+
@@ -63,11 +68,12 @@ func sendSSDPResponse(dst *net.UDPAddr, usn, location string) {
"\r\n",
time.Now().Format(time.RFC1123),
location,
- serviceType,
+ st,
usn,
- serviceType,
+ st,
)
+ // envoie UDP
conn, err := net.DialUDP("udp4", nil, dst)
if err != nil {
log.Println("Failed to dial UDP to respond:", err)
diff --git a/internal/ssdp/ssdp.go b/internal/ssdp/ssdp.go
index 4d340135..2f5149f6 100644
--- a/internal/ssdp/ssdp.go
+++ b/internal/ssdp/ssdp.go
@@ -2,7 +2,6 @@ package ssdp
import (
"log"
- "net"
"time"
"github.com/koron/go-ssdp"
@@ -32,12 +31,3 @@ func AnnounceRenderer(usn, location string) {
// Un jour on voudra faire ça à l'arrêt :
// 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()
-}
diff --git a/internal/upnp/http.go b/internal/upnp/http.go
index 58a9c859..bb162595 100644
--- a/internal/upnp/http.go
+++ b/internal/upnp/http.go
@@ -2,30 +2,16 @@ package upnp
import (
"fmt"
- "log"
- "net/http"
-
- "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap"
)
-func StartHTTPServer(usn 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 {
+func generateDeviceDescription(usn, ip string, port uint) string {
return fmt.Sprintf(`
1
0
+ http://%s:%d/
urn:schemas-upnp-org:device:MediaRenderer:1
pmomusic Fake Renderer
@@ -36,7 +22,7 @@ func generateDeviceDescription(usn string) string {
1.0
http://example.com/model
%s
- http://%s
+ http://%s:%d
urn:schemas-upnp-org:service:AVTransport:1
@@ -61,5 +47,5 @@ func generateDeviceDescription(usn string) string {
-`, usn, "127.0.0.1:1400") // à adapter à ton IP:port réel
+`, ip, port, usn, ip, port)
}
diff --git a/internal/upnp/server.go b/internal/upnp/server.go
new file mode 100644
index 00000000..b69ef18c
--- /dev/null
+++ b/internal/upnp/server.go
@@ -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))
+}
diff --git a/internal/upnp/xml/AVTransport.xml b/internal/upnp/xml/AVTransport.xml
new file mode 100644
index 00000000..725d8d31
--- /dev/null
+++ b/internal/upnp/xml/AVTransport.xml
@@ -0,0 +1,81 @@
+
+
+
+ 1
+ 0
+
+
+
+ SetAVTransportURI
+
+
+ InstanceID
+ in
+ A_ARG_TYPE_InstanceID
+
+
+ CurrentURI
+ in
+ AVTransportURI
+
+
+ CurrentURIMetaData
+ in
+ AVTransportURIMetaData
+
+
+
+
+
+ Play
+
+
+ InstanceID
+ in
+ A_ARG_TYPE_InstanceID
+
+
+ Speed
+ in
+ TransportPlaySpeed
+
+
+
+
+
+ Stop
+
+
+ InstanceID
+ in
+ A_ARG_TYPE_InstanceID
+
+
+
+
+
+
+
+ A_ARG_TYPE_InstanceID
+ ui4
+
+
+
+ AVTransportURI
+ string
+
+
+
+ AVTransportURIMetaData
+ string
+
+
+
+ TransportPlaySpeed
+ string
+
+ 1
+
+
+
+
diff --git a/internal/upnp/xml/ConnectionManager.xml b/internal/upnp/xml/ConnectionManager.xml
new file mode 100644
index 00000000..d12f286e
--- /dev/null
+++ b/internal/upnp/xml/ConnectionManager.xml
@@ -0,0 +1,31 @@
+
+
+ 10
+
+
+ GetProtocolInfo
+
+
+ Source
+ out
+ SourceProtocolInfo
+
+
+ Sink
+ out
+ SinkProtocolInfo
+
+
+
+
+
+
+ SourceProtocolInfo
+ string
+
+
+ SinkProtocolInfo
+ string
+
+
+
diff --git a/internal/upnp/xml/RenderingControl.xml b/internal/upnp/xml/RenderingControl.xml
new file mode 100644
index 00000000..91fe5067
--- /dev/null
+++ b/internal/upnp/xml/RenderingControl.xml
@@ -0,0 +1,43 @@
+
+
+ 10
+
+
+ SetVolume
+
+
+ InstanceID
+ in
+ A_ARG_TYPE_InstanceID
+
+
+ Channel
+ in
+ A_ARG_TYPE_Channel
+
+
+ DesiredVolume
+ in
+ Volume
+
+
+
+
+
+
+ A_ARG_TYPE_InstanceID
+ ui4
+
+
+ A_ARG_TYPE_Channel
+ string
+
+ Master
+
+
+
+ Volume
+ ui2
+
+
+