Changes to be committed:

modified:   cmd/pmomusic/main.go
	modified:   go.mod
	modified:   go.sum
	modified:   internal/ssdp/responder.go
	modified:   internal/ssdp/ssdp.go
	new file:   internal/ssdp/uuid.go
	renamed:    internal/upnp/http.go -> internal/upnp/description_xml.go
	modified:   internal/upnp/device.go
	renamed:    web/index.html -> internal/upnp/html/index.html
	modified:   internal/upnp/server.go
	modified:   internal/upnp/service.go
	new file:   internal/upnp/xml/Info.xml
	new file:   internal/upnp/xml/Playlist.xml
	new file:   internal/upnp/xml/Product.xml
This commit is contained in:
2025-06-08 21:39:58 +02:00
parent 2e80eb85d2
commit 2a8bba25a1
14 changed files with 645 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
package main
import (
"log"
log "github.com/sirupsen/logrus"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/netutils"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp"
@@ -15,14 +15,27 @@ func main() {
log.Fatalf("Could not guess local IP: %v", err)
}
location := "http://" + localIP + ":1400/description.xml"
usn := "uuid:pmomusic-renderer-001"
usn := ssdp.GetUUID()
desc := upnp.NewDevice(localIP, 1400, usn, "pmomusic Fake Renderer", "pmomusic")
desc.RegisterService("urn:schemas-upnp-org:service:AVTransport:1")
desc.RegisterService("urn:schemas-upnp-org:service:RenderingControl:1")
desc.RegisterService("urn:schemas-upnp-org:service:ConnectionManager:1")
desc.RegisterService("urn:av-openhome-org:service:Product:1")
desc.RegisterService("urn:av-openhome-org:service:Playlist:1")
desc.RegisterService("urn:av-openhome-org:service:Info:1")
location := "http://" + localIP + ":1400/description.xml"
log.Printf("Server UUID: %s", usn)
log.Info(desc.GenerateXML())
// Démarre la diffusion SSDP
go ssdp.AnnounceRenderer(usn, location)
go ssdp.StartSSDPResponder(usn, location)
go ssdp.AnnounceRenderer(desc)
go ssdp.StartSSDPResponder(desc)
// Lance le serveur HTTP
log.Println("Starting HTTP server at:", location)
upnp.StartHTTPServer(usn, localIP, 1400)
upnp.StartHTTPServer(desc)
}

6
go.mod
View File

@@ -2,7 +2,11 @@ module gargoton.petite-maison-orange.fr/eric/pmomusic
go 1.24.2
require github.com/koron/go-ssdp v0.0.6
require (
github.com/google/uuid v1.6.0
github.com/koron/go-ssdp v0.0.6
github.com/sirupsen/logrus v1.9.3
)
require (
golang.org/x/net v0.39.0 // indirect

16
go.sum
View File

@@ -1,6 +1,22 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/koron/go-ssdp v0.0.6 h1:Jb0h04599eq/CY7rB5YEqPS83HmRfHP2azkxMN2rFtU=
github.com/koron/go-ssdp v0.0.6/go.mod h1:0R9LfRJGek1zWTjN3JUNlm5INCDYGpRDfAptnct63fI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,8 +4,11 @@ import (
"fmt"
"log"
"net"
"strconv"
"strings"
"time"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
)
const (
@@ -13,8 +16,19 @@ const (
serviceType = "urn:schemas-upnp-org:device:MediaRenderer:1"
)
func fromUDPAddress(src *net.UDPAddr) string {
from := src.String()
hostnames, err := net.LookupAddr(src.IP.String())
if err == nil {
from = strings.TrimSuffix(hostnames[0], ".") + ":" + strconv.Itoa(src.Port)
}
return from
}
// StartSSDPResponder listens for M-SEARCH requests and responds if they match
func StartSSDPResponder(usn, location string) {
func StartSSDPResponder(device *upnp.DeviceDescription) {
addr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
if err != nil {
log.Fatal("Failed to resolve SSDP address:", err)
@@ -41,7 +55,8 @@ func StartSSDPResponder(usn, location string) {
data := string(buf[:n])
if strings.HasPrefix(data, "M-SEARCH * HTTP/1.1") {
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", fromUDPAddress(src), st)
if st == "ssdp:all" ||
st == serviceType ||
@@ -49,27 +64,27 @@ func StartSSDPResponder(usn, location string) {
strings.HasPrefix(st, "urn:av-openhome-org:service:") ||
strings.HasPrefix(st, "urn:bubblesoftapps-com:service:") {
go sendSSDPResponse(src, usn, location, st)
go sendSSDPResponse(src, device, st)
}
}
}
}
func sendSSDPResponse(dst *net.UDPAddr, usn, location, st string) {
func sendSSDPResponse(dst *net.UDPAddr, device *upnp.DeviceDescription, st string) {
resp := fmt.Sprintf(
"HTTP/1.1 200 OK\r\n"+
"CACHE-CONTROL: max-age=1800\r\n"+
"DATE: %s\r\n"+
"EXT:\r\n"+
"LOCATION: %s\r\n"+
"LOCATION: %s:%d\r\n"+
"SERVER: pmomusic/1.0 UPnP/1.1 DLNARenderer/1.0\r\n"+
"ST: %s\r\n"+
"USN: %s::%s\r\n"+
"\r\n",
time.Now().Format(time.RFC1123),
location,
device.IP, device.Port,
st,
usn,
device.USN,
st,
)

View File

@@ -2,17 +2,19 @@ package ssdp
import (
"log"
"strconv"
"time"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
"github.com/koron/go-ssdp"
)
func AnnounceRenderer(usn, location string) {
func AnnounceRenderer(device *upnp.DeviceDescription) {
st := "urn:schemas-upnp-org:device:MediaRenderer:1"
server := "pmomusic/1.0 UPnP/1.1 DLNARenderer/1.0"
maxAge := 1800
_, err := ssdp.Advertise(st, usn+"::"+st, location, server, maxAge)
_, err := ssdp.Advertise(st, device.USN+"::"+st, device.IP+":"+strconv.Itoa(int(device.Port)), server, maxAge)
if err != nil {
log.Println("SSDP advertise error:", err)
return

42
internal/ssdp/uuid.go Normal file
View File

@@ -0,0 +1,42 @@
package ssdp
import (
"os"
"path/filepath"
"github.com/google/uuid"
)
var __UUID = ""
func loadOrCreateUUID() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
filePath := filepath.Join(configDir, "pmomusic", "uuid.txt")
os.MkdirAll(filepath.Dir(filePath), 0755)
if data, err := os.ReadFile(filePath); err == nil && len(data) > 0 {
return string(data), nil
}
id := "uuid:pmo-" + uuid.New().String()
if err := os.WriteFile(filePath, []byte(id), 0600); err != nil {
return "", err
}
return id, nil
}
func GetUUID() string {
if __UUID == "" {
u, err := loadOrCreateUUID()
if err != nil {
panic(err)
}
__UUID = u
}
return __UUID
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
)
func generateDeviceDescription(usn, ip string, port uint) string {
func generateDeviceDescription(device *DeviceDescription) string {
return fmt.Sprintf(`<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
@@ -45,7 +45,28 @@ func generateDeviceDescription(usn, ip string, port uint) string {
<eventSubURL>/upnp/event/ConnectionManager</eventSubURL>
<SCPDURL>/scpd/ConnectionManager.xml</SCPDURL>
</service>
<service>
<serviceType>urn:av-openhome-org:service:Product:1</serviceType>
<serviceId>urn:av-openhome-org:serviceId:Product</serviceId>
<controlURL>/upnp/control/Product</controlURL>
<eventSubURL>/upnp/event/Product</eventSubURL>
<SCPDURL>/scpd/Product.xml</SCPDURL>
</service>
<service>
<serviceType>urn:av-openhome-org:service:Playlist:1</serviceType>
<serviceId>urn:av-openhome-org:serviceId:Playlist</serviceId>
<controlURL>/upnp/control/Playlist</controlURL>
<eventSubURL>/upnp/event/Playlist</eventSubURL>
<SCPDURL>/scpd/Playlist.xml</SCPDURL>
</service>
<service>
<serviceType>urn:av-openhome-org:service:Info:1</serviceType>
<serviceId>urn:av-openhome-org:serviceId:Info</serviceId>
<controlURL>/upnp/control/Info</controlURL>
<eventSubURL>/upnp/event/Info</eventSubURL>
<SCPDURL>/scpd/Info.xml</SCPDURL>
</service>
</serviceList>
</device>
</root>`, ip, port, usn, ip, port)
</root>`, device.IP, device.Port, device.USN, device.IP, device.Port)
}

View File

@@ -1 +1,93 @@
package upnp
import (
"bytes"
"text/template"
log "github.com/sirupsen/logrus"
)
type DeviceDescription struct {
IP string
Port uint
USN string
Friendly string
ModelName string
Services *ServiceList
}
const deviceTemplate = `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>http://{{.IP}}:{{.Port}}/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>{{.Friendly}}</friendlyName>
<manufacturer>GoDLNA</manufacturer>
<manufacturerURL>http://example.com</manufacturerURL>
<modelDescription>Fake DLNA Renderer</modelDescription>
<modelName>{{.ModelName}}</modelName>
<modelNumber>1.0</modelNumber>
<modelURL>http://example.com/model</modelURL>
<UDN>{{.USN}}</UDN>
<presentationURL>http://{{.IP}}:{{.Port}}/</presentationURL>
{{.ServicesXML}}
</device>
</root>`
func NewDevice(ip string, port uint, usn string, friendly string, modelname string) *DeviceDescription {
services := NewServiceList()
return &DeviceDescription{
IP: ip,
Port: port,
USN: usn,
Friendly: friendly,
ModelName: modelname,
Services: services,
}
}
func (d *DeviceDescription) RegisterService(service string) {
d.Services.Append(service)
}
func (d *DeviceDescription) GenerateXML() (string, error) {
servicesXML, err := d.Services.GenerateServiceListXML()
if err != nil {
log.Errorf("Failed to generate service list XML: %v", err)
return "", err
}
tmpl, err := template.New("device").Parse(deviceTemplate)
if err != nil {
log.Errorf("Failed to parse device description template: %v", err)
return "", err
}
data := struct {
IP string
Port uint
USN string
Friendly string
ModelName string
ServicesXML string // trusted raw XML
}{
IP: d.IP,
Port: d.Port,
USN: d.USN,
Friendly: d.Friendly,
ModelName: d.ModelName,
ServicesXML: servicesXML,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
log.Errorf("Failed to execute template: %v", err)
return "", err
}
return buf.String(), nil
}

View File

@@ -13,6 +13,9 @@ import (
//go:embed xml/*.xml
var embeddedXML embed.FS
//go:embed html/*.html
var embeddedHTML embed.FS
// ServeStaticXML mounts handlers for SCPD XML files.
func ServeStaticXML(mux *http.ServeMux) {
subFS, err := fs.Sub(embeddedXML, "xml")
@@ -22,13 +25,28 @@ func ServeStaticXML(mux *http.ServeMux) {
mux.Handle("/scpd/", http.StripPrefix("/scpd/", http.FileServer(http.FS(subFS))))
}
func StartHTTPServer(usn, ip string, port uint) {
func ServeStaticHTML(mux *http.ServeMux) {
subFS, err := fs.Sub(embeddedHTML, "html")
if err != nil {
panic("failed to create sub FS: " + err.Error())
}
mux.Handle("/", http.FileServer(http.FS(subFS)))
}
func StartHTTPServer(device *DeviceDescription) {
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)))
xml, err := device.GenerateXML()
if err != nil {
w.WriteHeader(500)
w.Write([]byte(err.Error()))
return
}
w.Write([]byte(xml))
})
// SOAP control endpoints
@@ -38,8 +56,9 @@ func StartHTTPServer(usn, ip string, port uint) {
// Serve static SCPD XML files
ServeStaticXML(mux)
ServeStaticHTML(mux)
addr := fmt.Sprintf("%s:%d", ip, port)
addr := fmt.Sprintf("%s:%d", device.IP, device.Port)
log.Printf("Serving UPnP fake renderer at http://%s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}

View File

@@ -1 +1,115 @@
package upnp
import (
"bytes"
"fmt"
"strings"
"text/template"
)
type ServiceGroup string
const (
UPnP ServiceGroup = "upnp"
OpenHome ServiceGroup = "openhome"
Other ServiceGroup = "other"
)
type ServiceDescription struct {
Group ServiceGroup
Type string
Name string
ID string
URLPrefix string
XML string // auto-rempli temporairement pour le template parent
}
const serviceTemplate = `
<service>
<serviceType>{{.Type}}</serviceType>
<serviceId>{{.ID}}</serviceId>
<controlURL>{{.URLPrefix}}/control</controlURL>
<eventSubURL>{{.URLPrefix}}/event</eventSubURL>
<SCPDURL>{{.URLPrefix}}.xml</SCPDURL>
</service>
`
func NewServiceDescription(serviceType string) *ServiceDescription {
parts := strings.Split(serviceType, ":")
name := parts[len(parts)-2]
var group ServiceGroup
switch {
case strings.Contains(serviceType, "schemas-upnp-org"):
group = UPnP
case strings.Contains(serviceType, "av-openhome-org"):
group = OpenHome
default:
group = Other
}
id := fmt.Sprintf("urn:%s:serviceId:%s", string(group), name)
urlPrefix := fmt.Sprintf("/%s/%s", group, name)
return &ServiceDescription{
Group: group,
Type: serviceType,
Name: name,
ID: id,
URLPrefix: urlPrefix,
}
}
func (desc *ServiceDescription) GenerateServiceXML() (string, error) {
tmpl, err := template.New("service").Parse(strings.TrimSpace(serviceTemplate))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, desc); err != nil {
return "", err
}
return buf.String(), nil
}
type ServiceList struct {
Services []*ServiceDescription
}
func NewServiceList() *ServiceList {
return &ServiceList{
Services: make([]*ServiceDescription, 0),
}
}
func (sl *ServiceList) Append(serviceType string) {
svc := NewServiceDescription(serviceType)
sl.Services = append(sl.Services, svc)
}
func (sl *ServiceList) GenerateServiceListXML() (string, error) {
const listTemplate = `
<serviceList>
{{range .Services}}{{.XML}}
{{end}}</serviceList>`
// Génère tous les XML individuellement
for _, svc := range sl.Services {
xml, err := svc.GenerateServiceXML()
if err != nil {
return "", err
}
svc.XML = xml
}
// Template principal
tmpl, err := template.New("list").Parse(strings.TrimSpace(listTemplate))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, sl); err != nil {
return "", err
}
return buf.String(), nil
}

111
internal/upnp/xml/Info.xml Normal file
View File

@@ -0,0 +1,111 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Track</name>
<argumentList>
<argument>
<name>Uri</name>
<direction>out</direction>
<relatedStateVariable>TrackUri</relatedStateVariable>
</argument>
<argument>
<name>Metadata</name>
<direction>out</direction>
<relatedStateVariable>TrackMetadata</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Details</name>
<argumentList>
<argument>
<name>Duration</name>
<direction>out</direction>
<relatedStateVariable>TrackDuration</relatedStateVariable>
</argument>
<argument>
<name>BitRate</name>
<direction>out</direction>
<relatedStateVariable>TrackBitRate</relatedStateVariable>
</argument>
<argument>
<name>BitDepth</name>
<direction>out</direction>
<relatedStateVariable>TrackBitDepth</relatedStateVariable>
</argument>
<argument>
<name>SampleRate</name>
<direction>out</direction>
<relatedStateVariable>TrackSampleRate</relatedStateVariable>
</argument>
<argument>
<name>Lossless</name>
<direction>out</direction>
<relatedStateVariable>TrackLossless</relatedStateVariable>
</argument>
<argument>
<name>CodecName</name>
<direction>out</direction>
<relatedStateVariable>TrackCodecName</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Metatext</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Metatext</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>TrackUri</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackMetadata</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackDuration</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackBitRate</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackBitDepth</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackSampleRate</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackLossless</name>
<dataType>boolean</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TrackCodecName</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>Metatext</name>
<dataType>string</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Play</name>
<argumentList>
<argument>
<name>Value</name>
<direction>in</direction>
<relatedStateVariable>TransportState</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Pause</name>
</action>
<action>
<name>Stop</name>
</action>
<action>
<name>Next</name>
</action>
<action>
<name>Previous</name>
</action>
<action>
<name>Insert</name>
<argumentList>
<argument>
<name>AfterId</name>
<direction>in</direction>
<relatedStateVariable>TrackId</relatedStateVariable>
</argument>
<argument>
<name>Uri</name>
<direction>in</direction>
<relatedStateVariable>Uri</relatedStateVariable>
</argument>
<argument>
<name>Metadata</name>
<direction>in</direction>
<relatedStateVariable>Metadata</relatedStateVariable>
</argument>
<argument>
<name>NewId</name>
<direction>out</direction>
<relatedStateVariable>TrackId</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>TransportState</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Playing</allowedValue>
<allowedValue>Paused</allowedValue>
<allowedValue>Stopped</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>Uri</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Metadata</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TrackId</name>
<dataType>ui4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Manufacturer</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Manufacturer</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Model</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Model</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Product</name>
<argumentList>
<argument>
<name>Value</name>
<direction>out</direction>
<relatedStateVariable>Product</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Standby</name>
<argumentList>
<argument>
<name>Value</name>
<direction>in</direction>
<relatedStateVariable>Standby</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>SetStandby</name>
<argumentList>
<argument>
<name>Value</name>
<direction>in</direction>
<relatedStateVariable>Standby</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>Manufacturer</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Model</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Product</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>Standby</name>
<dataType>boolean</dataType>
</stateVariable>
</serviceStateTable>
</scpd>