On avance...
This commit is contained in:
@@ -2,45 +2,30 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/renderer"
|
log "github.com/sirupsen/logrus"
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(
|
||||||
|
context.Background(),
|
||||||
|
syscall.SIGINT,
|
||||||
|
syscall.SIGTERM,
|
||||||
|
)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
// Crée le serveur avec baseURL auto-déduite depuis l’IP locale
|
// Crée le serveur avec baseURL auto-déduite depuis l’IP locale
|
||||||
server := upnp.NewServer("PMO Music Server", "PMO Factory", "Fake Server", "", 1400)
|
server := upnp.NewServer("pmomusic")
|
||||||
|
|
||||||
// Crée le renderer UPnP
|
server.RegisterDevice("", mediarenderer.FakeRenderer)
|
||||||
rendererDevice := renderer.NewMusicRenderer("pmomusic Fake Renderer", "pmomusic", "fake model")
|
|
||||||
server.RegisterDevice("MusicRenderer", rendererDevice)
|
|
||||||
|
|
||||||
// Lance le serveur HTTP
|
if err := server.Run(ctx); err != nil {
|
||||||
if err := server.Start(); err != nil {
|
log.Fatalf("server error: %v", err)
|
||||||
log.Fatalf("Failed to start UPnP server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gère les signaux pour un arrêt propre
|
|
||||||
sigs := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
log.Println("UPnP MusicRenderer is running... Press Ctrl+C to stop.")
|
|
||||||
<-sigs
|
|
||||||
|
|
||||||
log.Println("Shutting down...")
|
|
||||||
|
|
||||||
// Dé-annonce SSDP (optionnel)
|
|
||||||
server.NotifyByeBye()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := server.Stop(ctx); err != nil {
|
|
||||||
log.Printf("Error shutting down UPnP server: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
doc/Type_devices.md
Normal file
88
doc/Type_devices.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
Voici un tableau complet des devices et services UPnP/DLNA pertinents pour un **serveur audio**, prêt à structurer dans ton code :
|
||||||
|
|
||||||
|
| Device type | UPnP class | Rôle | Services indispensables | Description |
|
||||||
|
| ------------------------------ | --------------------------------------------- | -------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| MediaServer (MS) | `urn:schemas-upnp-org:device:MediaServer:1` | Fournit des fichiers audio/vidéo | `ContentDirectory`, `ConnectionManager` | Permet aux clients de parcourir et lire le contenu stocké sur le serveur |
|
||||||
|
| MediaRenderer (MR) | `urn:schemas-upnp-org:device:MediaRenderer:1` | Reçoit et lit l’audio | `RenderingControl`, `AVTransport`, `ConnectionManager` | Lecteur audio DLNA : contrôle de volume, transport (play/pause/stop), gestion des connexions |
|
||||||
|
| Digital Media Controller (DMC) | Pas de device standard, souvent logiciel | Orchestration entre MS et MR | Contrôle MR via `AVTransport` et `RenderingControl` | Contrôle à distance la lecture et le flux audio sur un ou plusieurs MR |
|
||||||
|
| Playlist Server (optionnel) | Dépend du vendor | Gère playlists | Souvent custom | Fournit des listes de lecture pour les MR, parfois intégré au MS |
|
||||||
|
| Remote UI / Control Point | `urn:schemas-upnp-org:device:ControlPoint:1` | Interface de contrôle | Aucun service UPnP standard | Applications ou UI qui pilotent la lecture sur MR/MS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Services clés pour un **MediaRenderer audio**
|
||||||
|
|
||||||
|
| Service | SCPD / Actions principales | Description |
|
||||||
|
| --------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
|
| `RenderingControl:1` | `SetVolume`, `GetVolume`, `SetMute`, `GetMute` | Contrôle du volume et mute |
|
||||||
|
| `AVTransport:1` | `SetAVTransportURI`, `Play`, `Pause`, `Stop`, `Seek` | Commandes de lecture audio/vidéo |
|
||||||
|
| `ConnectionManager:1` | `GetProtocolInfo`, `PrepareForConnection` | Informations sur les protocoles supportés et gestion de session |
|
||||||
|
|
||||||
|
### Résumé pour un **MediaServer audio minimal**
|
||||||
|
|
||||||
|
| Service | Actions clés | Notes |
|
||||||
|
| --------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `ContentDirectory:1` | `Browse`, `Search`, `GetSystemUpdateID` | Obligatoire pour parcourir le contenu audio |
|
||||||
|
| `ConnectionManager:1` | `GetProtocolInfo`, `PrepareForConnection`, `ConnectionComplete` | Obligatoire pour que les MediaRenderers puissent lire le contenu |
|
||||||
|
| Autres | optionnels | Selon fonctionnalités avancées (playlists, enregistrement, etc.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bonnes pratiques
|
||||||
|
|
||||||
|
1. **UDN unique et persistant** pour chaque device.
|
||||||
|
2. **Hierarchie de device** : MediaServer et MediaRenderer peuvent être enfants ou racines selon le schéma Mermaid que tu utilises.
|
||||||
|
3. **URLs SCPD et services** doivent suivre le schéma :
|
||||||
|
|
||||||
|
```
|
||||||
|
/device/<device-type>/<udn>/desc.xml
|
||||||
|
/device/<device-type>/<udn>/service/<service>.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Si ton serveur est audio-only, **MediaRenderer est suffisant** pour exposer l’interface de lecture et contrôle, MediaServer si tu fournis du contenu.
|
||||||
|
|
||||||
|
|
||||||
|
Pour un **MediaServer (MS)** UPnP/DLNA, les services standard à implémenter sont bien définis dans les specs **UPnP AV** et **DLNA Guidelines** :
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. **ContentDirectory:1**
|
||||||
|
|
||||||
|
* **Rôle** : exposer le contenu multimédia (audio, vidéo, images).
|
||||||
|
|
||||||
|
* **Actions principales** :
|
||||||
|
|
||||||
|
* `Browse` : lister les objets (dossiers/fichiers) dans la bibliothèque.
|
||||||
|
* `Search` : rechercher des objets selon des critères.
|
||||||
|
* `GetSystemUpdateID` : pour détecter les modifications dans la bibliothèque.
|
||||||
|
* `GetSortCapabilities` / `GetSearchCapabilities` : métadonnées supportées.
|
||||||
|
|
||||||
|
* **URL SCPD** : `/device/<devicetype>/<udn>/service/ContentDirectory.xml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **ConnectionManager:1**
|
||||||
|
|
||||||
|
* **Rôle** : gérer les connexions et informer sur les protocoles supportés.
|
||||||
|
|
||||||
|
* **Actions principales** :
|
||||||
|
|
||||||
|
* `GetProtocolInfo` : retourne les protocoles de lecture supportés (DLNA profile, MIME type).
|
||||||
|
* `PrepareForConnection` / `ConnectionComplete` : notification de début/fin de connexion entre MS et MR.
|
||||||
|
* `GetCurrentConnectionIDs` / `GetCurrentConnectionInfo` : état des connexions.
|
||||||
|
|
||||||
|
* **URL SCPD** : `/device/<devicetype>/<udn>/service/ConnectionManager.xml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Optional / Extensions**
|
||||||
|
|
||||||
|
Certains MediaServer ajoutent des services supplémentaires :
|
||||||
|
|
||||||
|
* `ScheduledRecording` : pour enregistrer du contenu.
|
||||||
|
* `ImportResource` : pour ajouter dynamiquement des fichiers.
|
||||||
|
* Services vendor-specific pour métadonnées enrichies ou playlists.
|
||||||
|
* Chaque **MediaServer doit avoir un UDN persistant**.
|
||||||
|
* Les services doivent renvoyer des **protocolInfo** corrects pour le DLNA audio (ex : `http-get:*:audio/mpeg:*`).
|
||||||
|
|
||||||
|
---
|
||||||
497
doc/schema.html
Normal file
497
doc/schema.html
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"><head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="generator" content="quarto-1.7.32">
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
||||||
|
|
||||||
|
|
||||||
|
<title>schema</title>
|
||||||
|
<style>
|
||||||
|
code{white-space: pre-wrap;}
|
||||||
|
span.smallcaps{font-variant: small-caps;}
|
||||||
|
div.columns{display: flex; gap: min(4vw, 1.5em);}
|
||||||
|
div.column{flex: auto; overflow-x: auto;}
|
||||||
|
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||||||
|
ul.task-list{list-style: none;}
|
||||||
|
ul.task-list li input[type="checkbox"] {
|
||||||
|
width: 0.8em;
|
||||||
|
margin: 0 0.8em 0.2em -1em; /* quarto-specific, see https://github.com/quarto-dev/quarto-cli/issues/4556 */
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="schema_files/libs/clipboard/clipboard.min.js"></script>
|
||||||
|
<script src="schema_files/libs/quarto-html/quarto.js" type="module"></script>
|
||||||
|
<script src="schema_files/libs/quarto-html/tabsets/tabsets.js" type="module"></script>
|
||||||
|
<script src="schema_files/libs/quarto-html/popper.min.js"></script>
|
||||||
|
<script src="schema_files/libs/quarto-html/tippy.umd.min.js"></script>
|
||||||
|
<script src="schema_files/libs/quarto-html/anchor.min.js"></script>
|
||||||
|
<link href="schema_files/libs/quarto-html/tippy.css" rel="stylesheet">
|
||||||
|
<link href="schema_files/libs/quarto-html/quarto-syntax-highlighting-37eea08aefeeee20ff55810ff984fec1.css" rel="stylesheet" id="quarto-text-highlighting-styles">
|
||||||
|
<script src="schema_files/libs/bootstrap/bootstrap.min.js"></script>
|
||||||
|
<link href="schema_files/libs/bootstrap/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="schema_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css" rel="stylesheet" append-hash="true" id="quarto-bootstrap" data-mode="light">
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="fullcontent quarto-light">
|
||||||
|
|
||||||
|
<div id="quarto-content" class="page-columns page-rows-contents page-layout-article">
|
||||||
|
|
||||||
|
<main class="content" id="quarto-document-content">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<section id="architecture-upnpdlna" class="level1">
|
||||||
|
<h1>Architecture UPnP/DLNA</h1>
|
||||||
|
<p>Voici la séparation <strong>définition</strong> vs <strong>runtime</strong> :</p>
|
||||||
|
<pre class="mermaid"><code>classDiagram
|
||||||
|
%% Definition Layer
|
||||||
|
class Device
|
||||||
|
class Service
|
||||||
|
class Action
|
||||||
|
class StateVariable
|
||||||
|
|
||||||
|
%% Runtime Layer
|
||||||
|
class Server
|
||||||
|
class DeviceInstance
|
||||||
|
class ServiceInstance
|
||||||
|
class StateVariableInstance
|
||||||
|
class ActionHandler
|
||||||
|
|
||||||
|
%% Associations Definition Layer
|
||||||
|
Device "1" --> "1..*" Service
|
||||||
|
Device "0..*" --> "0..*" Device
|
||||||
|
Service "0..*" --> "0..*" Action
|
||||||
|
Service "1..*" --> "1..*" StateVariable
|
||||||
|
|
||||||
|
%% Associations Runtime Layer
|
||||||
|
Server "1" --> "1..*" DeviceInstance
|
||||||
|
DeviceInstance "1" --> "1..*" ServiceInstance
|
||||||
|
DeviceInstance "0..*" --> "0..*" DeviceInstance
|
||||||
|
ServiceInstance "1" --> "1..*" StateVariableInstance
|
||||||
|
ServiceInstance "0..*" --> "0..*" ActionHandler
|
||||||
|
|
||||||
|
%% Instantiation links
|
||||||
|
DeviceInstance ..> Device : instantiates
|
||||||
|
ServiceInstance ..> Service : instantiates
|
||||||
|
StateVariableInstance ..> StateVariable : instantiates
|
||||||
|
ActionHandler ..> Action : handles
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
class Device,Service,Action,StateVariable fill:#f9f,stroke:#333,stroke-width:1px
|
||||||
|
class Server,DeviceInstance,ServiceInstance,StateVariableInstance,ActionHandler fill:#9f9,stroke:#333,stroke-width:1px</code></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<!-- /main column -->
|
||||||
|
<script id="quarto-html-after-body" type="application/javascript">
|
||||||
|
window.document.addEventListener("DOMContentLoaded", function (event) {
|
||||||
|
const icon = "";
|
||||||
|
const anchorJS = new window.AnchorJS();
|
||||||
|
anchorJS.options = {
|
||||||
|
placement: 'right',
|
||||||
|
icon: icon
|
||||||
|
};
|
||||||
|
anchorJS.add('.anchored');
|
||||||
|
const isCodeAnnotation = (el) => {
|
||||||
|
for (const clz of el.classList) {
|
||||||
|
if (clz.startsWith('code-annotation-')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const onCopySuccess = function(e) {
|
||||||
|
// button target
|
||||||
|
const button = e.trigger;
|
||||||
|
// don't keep focus
|
||||||
|
button.blur();
|
||||||
|
// flash "checked"
|
||||||
|
button.classList.add('code-copy-button-checked');
|
||||||
|
var currentTitle = button.getAttribute("title");
|
||||||
|
button.setAttribute("title", "Copied!");
|
||||||
|
let tooltip;
|
||||||
|
if (window.bootstrap) {
|
||||||
|
button.setAttribute("data-bs-toggle", "tooltip");
|
||||||
|
button.setAttribute("data-bs-placement", "left");
|
||||||
|
button.setAttribute("data-bs-title", "Copied!");
|
||||||
|
tooltip = new bootstrap.Tooltip(button,
|
||||||
|
{ trigger: "manual",
|
||||||
|
customClass: "code-copy-button-tooltip",
|
||||||
|
offset: [0, -8]});
|
||||||
|
tooltip.show();
|
||||||
|
}
|
||||||
|
setTimeout(function() {
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.hide();
|
||||||
|
button.removeAttribute("data-bs-title");
|
||||||
|
button.removeAttribute("data-bs-toggle");
|
||||||
|
button.removeAttribute("data-bs-placement");
|
||||||
|
}
|
||||||
|
button.setAttribute("title", currentTitle);
|
||||||
|
button.classList.remove('code-copy-button-checked');
|
||||||
|
}, 1000);
|
||||||
|
// clear code selection
|
||||||
|
e.clearSelection();
|
||||||
|
}
|
||||||
|
const getTextToCopy = function(trigger) {
|
||||||
|
const codeEl = trigger.previousElementSibling.cloneNode(true);
|
||||||
|
for (const childEl of codeEl.children) {
|
||||||
|
if (isCodeAnnotation(childEl)) {
|
||||||
|
childEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codeEl.innerText;
|
||||||
|
}
|
||||||
|
const clipboard = new window.ClipboardJS('.code-copy-button:not([data-in-quarto-modal])', {
|
||||||
|
text: getTextToCopy
|
||||||
|
});
|
||||||
|
clipboard.on('success', onCopySuccess);
|
||||||
|
if (window.document.getElementById('quarto-embedded-source-code-modal')) {
|
||||||
|
const clipboardModal = new window.ClipboardJS('.code-copy-button[data-in-quarto-modal]', {
|
||||||
|
text: getTextToCopy,
|
||||||
|
container: window.document.getElementById('quarto-embedded-source-code-modal')
|
||||||
|
});
|
||||||
|
clipboardModal.on('success', onCopySuccess);
|
||||||
|
}
|
||||||
|
var localhostRegex = new RegExp(/^(?:http|https):\/\/localhost\:?[0-9]*\//);
|
||||||
|
var mailtoRegex = new RegExp(/^mailto:/);
|
||||||
|
var filterRegex = new RegExp('/' + window.location.host + '/');
|
||||||
|
var isInternal = (href) => {
|
||||||
|
return filterRegex.test(href) || localhostRegex.test(href) || mailtoRegex.test(href);
|
||||||
|
}
|
||||||
|
// Inspect non-navigation links and adorn them if external
|
||||||
|
var links = window.document.querySelectorAll('a[href]:not(.nav-link):not(.navbar-brand):not(.toc-action):not(.sidebar-link):not(.sidebar-item-toggle):not(.pagination-link):not(.no-external):not([aria-hidden]):not(.dropdown-item):not(.quarto-navigation-tool):not(.about-link)');
|
||||||
|
for (var i=0; i<links.length; i++) {
|
||||||
|
const link = links[i];
|
||||||
|
if (!isInternal(link.href)) {
|
||||||
|
// undo the damage that might have been done by quarto-nav.js in the case of
|
||||||
|
// links that we want to consider external
|
||||||
|
if (link.dataset.originalHref !== undefined) {
|
||||||
|
link.href = link.dataset.originalHref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tippyHover(el, contentFn, onTriggerFn, onUntriggerFn) {
|
||||||
|
const config = {
|
||||||
|
allowHTML: true,
|
||||||
|
maxWidth: 500,
|
||||||
|
delay: 100,
|
||||||
|
arrow: false,
|
||||||
|
appendTo: function(el) {
|
||||||
|
return el.parentElement;
|
||||||
|
},
|
||||||
|
interactive: true,
|
||||||
|
interactiveBorder: 10,
|
||||||
|
theme: 'quarto',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
};
|
||||||
|
if (contentFn) {
|
||||||
|
config.content = contentFn;
|
||||||
|
}
|
||||||
|
if (onTriggerFn) {
|
||||||
|
config.onTrigger = onTriggerFn;
|
||||||
|
}
|
||||||
|
if (onUntriggerFn) {
|
||||||
|
config.onUntrigger = onUntriggerFn;
|
||||||
|
}
|
||||||
|
window.tippy(el, config);
|
||||||
|
}
|
||||||
|
const noterefs = window.document.querySelectorAll('a[role="doc-noteref"]');
|
||||||
|
for (var i=0; i<noterefs.length; i++) {
|
||||||
|
const ref = noterefs[i];
|
||||||
|
tippyHover(ref, function() {
|
||||||
|
// use id or data attribute instead here
|
||||||
|
let href = ref.getAttribute('data-footnote-href') || ref.getAttribute('href');
|
||||||
|
try { href = new URL(href).hash; } catch {}
|
||||||
|
const id = href.replace(/^#\/?/, "");
|
||||||
|
const note = window.document.getElementById(id);
|
||||||
|
if (note) {
|
||||||
|
return note.innerHTML;
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const xrefs = window.document.querySelectorAll('a.quarto-xref');
|
||||||
|
const processXRef = (id, note) => {
|
||||||
|
// Strip column container classes
|
||||||
|
const stripColumnClz = (el) => {
|
||||||
|
el.classList.remove("page-full", "page-columns");
|
||||||
|
if (el.children) {
|
||||||
|
for (const child of el.children) {
|
||||||
|
stripColumnClz(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stripColumnClz(note)
|
||||||
|
if (id === null || id.startsWith('sec-')) {
|
||||||
|
// Special case sections, only their first couple elements
|
||||||
|
const container = document.createElement("div");
|
||||||
|
if (note.children && note.children.length > 2) {
|
||||||
|
container.appendChild(note.children[0].cloneNode(true));
|
||||||
|
for (let i = 1; i < note.children.length; i++) {
|
||||||
|
const child = note.children[i];
|
||||||
|
if (child.tagName === "P" && child.innerText === "") {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
container.appendChild(child.cloneNode(true));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (window.Quarto?.typesetMath) {
|
||||||
|
window.Quarto.typesetMath(container);
|
||||||
|
}
|
||||||
|
return container.innerHTML
|
||||||
|
} else {
|
||||||
|
if (window.Quarto?.typesetMath) {
|
||||||
|
window.Quarto.typesetMath(note);
|
||||||
|
}
|
||||||
|
return note.innerHTML;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove any anchor links if they are present
|
||||||
|
const anchorLink = note.querySelector('a.anchorjs-link');
|
||||||
|
if (anchorLink) {
|
||||||
|
anchorLink.remove();
|
||||||
|
}
|
||||||
|
if (window.Quarto?.typesetMath) {
|
||||||
|
window.Quarto.typesetMath(note);
|
||||||
|
}
|
||||||
|
if (note.classList.contains("callout")) {
|
||||||
|
return note.outerHTML;
|
||||||
|
} else {
|
||||||
|
return note.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var i=0; i<xrefs.length; i++) {
|
||||||
|
const xref = xrefs[i];
|
||||||
|
tippyHover(xref, undefined, function(instance) {
|
||||||
|
instance.disable();
|
||||||
|
let url = xref.getAttribute('href');
|
||||||
|
let hash = undefined;
|
||||||
|
if (url.startsWith('#')) {
|
||||||
|
hash = url;
|
||||||
|
} else {
|
||||||
|
try { hash = new URL(url).hash; } catch {}
|
||||||
|
}
|
||||||
|
if (hash) {
|
||||||
|
const id = hash.replace(/^#\/?/, "");
|
||||||
|
const note = window.document.getElementById(id);
|
||||||
|
if (note !== null) {
|
||||||
|
try {
|
||||||
|
const html = processXRef(id, note.cloneNode(true));
|
||||||
|
instance.setContent(html);
|
||||||
|
} finally {
|
||||||
|
instance.enable();
|
||||||
|
instance.show();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// See if we can fetch this
|
||||||
|
fetch(url.split('#')[0])
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(html => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const htmlDoc = parser.parseFromString(html, "text/html");
|
||||||
|
const note = htmlDoc.getElementById(id);
|
||||||
|
if (note !== null) {
|
||||||
|
const html = processXRef(id, note);
|
||||||
|
instance.setContent(html);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
instance.enable();
|
||||||
|
instance.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// See if we can fetch a full url (with no hash to target)
|
||||||
|
// This is a special case and we should probably do some content thinning / targeting
|
||||||
|
fetch(url)
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(html => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const htmlDoc = parser.parseFromString(html, "text/html");
|
||||||
|
const note = htmlDoc.querySelector('main.content');
|
||||||
|
if (note !== null) {
|
||||||
|
// This should only happen for chapter cross references
|
||||||
|
// (since there is no id in the URL)
|
||||||
|
// remove the first header
|
||||||
|
if (note.children.length > 0 && note.children[0].tagName === "HEADER") {
|
||||||
|
note.children[0].remove();
|
||||||
|
}
|
||||||
|
const html = processXRef(null, note);
|
||||||
|
instance.setContent(html);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
instance.enable();
|
||||||
|
instance.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, function(instance) {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let selectedAnnoteEl;
|
||||||
|
const selectorForAnnotation = ( cell, annotation) => {
|
||||||
|
let cellAttr = 'data-code-cell="' + cell + '"';
|
||||||
|
let lineAttr = 'data-code-annotation="' + annotation + '"';
|
||||||
|
const selector = 'span[' + cellAttr + '][' + lineAttr + ']';
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
const selectCodeLines = (annoteEl) => {
|
||||||
|
const doc = window.document;
|
||||||
|
const targetCell = annoteEl.getAttribute("data-target-cell");
|
||||||
|
const targetAnnotation = annoteEl.getAttribute("data-target-annotation");
|
||||||
|
const annoteSpan = window.document.querySelector(selectorForAnnotation(targetCell, targetAnnotation));
|
||||||
|
const lines = annoteSpan.getAttribute("data-code-lines").split(",");
|
||||||
|
const lineIds = lines.map((line) => {
|
||||||
|
return targetCell + "-" + line;
|
||||||
|
})
|
||||||
|
let top = null;
|
||||||
|
let height = null;
|
||||||
|
let parent = null;
|
||||||
|
if (lineIds.length > 0) {
|
||||||
|
//compute the position of the single el (top and bottom and make a div)
|
||||||
|
const el = window.document.getElementById(lineIds[0]);
|
||||||
|
top = el.offsetTop;
|
||||||
|
height = el.offsetHeight;
|
||||||
|
parent = el.parentElement.parentElement;
|
||||||
|
if (lineIds.length > 1) {
|
||||||
|
const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]);
|
||||||
|
const bottom = lastEl.offsetTop + lastEl.offsetHeight;
|
||||||
|
height = bottom - top;
|
||||||
|
}
|
||||||
|
if (top !== null && height !== null && parent !== null) {
|
||||||
|
// cook up a div (if necessary) and position it
|
||||||
|
let div = window.document.getElementById("code-annotation-line-highlight");
|
||||||
|
if (div === null) {
|
||||||
|
div = window.document.createElement("div");
|
||||||
|
div.setAttribute("id", "code-annotation-line-highlight");
|
||||||
|
div.style.position = 'absolute';
|
||||||
|
parent.appendChild(div);
|
||||||
|
}
|
||||||
|
div.style.top = top - 2 + "px";
|
||||||
|
div.style.height = height + 4 + "px";
|
||||||
|
div.style.left = 0;
|
||||||
|
let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter");
|
||||||
|
if (gutterDiv === null) {
|
||||||
|
gutterDiv = window.document.createElement("div");
|
||||||
|
gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter");
|
||||||
|
gutterDiv.style.position = 'absolute';
|
||||||
|
const codeCell = window.document.getElementById(targetCell);
|
||||||
|
const gutter = codeCell.querySelector('.code-annotation-gutter');
|
||||||
|
gutter.appendChild(gutterDiv);
|
||||||
|
}
|
||||||
|
gutterDiv.style.top = top - 2 + "px";
|
||||||
|
gutterDiv.style.height = height + 4 + "px";
|
||||||
|
}
|
||||||
|
selectedAnnoteEl = annoteEl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unselectCodeLines = () => {
|
||||||
|
const elementsIds = ["code-annotation-line-highlight", "code-annotation-line-highlight-gutter"];
|
||||||
|
elementsIds.forEach((elId) => {
|
||||||
|
const div = window.document.getElementById(elId);
|
||||||
|
if (div) {
|
||||||
|
div.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectedAnnoteEl = undefined;
|
||||||
|
};
|
||||||
|
// Handle positioning of the toggle
|
||||||
|
window.addEventListener(
|
||||||
|
"resize",
|
||||||
|
throttle(() => {
|
||||||
|
elRect = undefined;
|
||||||
|
if (selectedAnnoteEl) {
|
||||||
|
selectCodeLines(selectedAnnoteEl);
|
||||||
|
}
|
||||||
|
}, 10)
|
||||||
|
);
|
||||||
|
function throttle(fn, ms) {
|
||||||
|
let throttle = false;
|
||||||
|
let timer;
|
||||||
|
return (...args) => {
|
||||||
|
if(!throttle) { // first call gets through
|
||||||
|
fn.apply(this, args);
|
||||||
|
throttle = true;
|
||||||
|
} else { // all the others get throttled
|
||||||
|
if(timer) clearTimeout(timer); // cancel #2
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fn.apply(this, args);
|
||||||
|
timer = throttle = false;
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Attach click handler to the DT
|
||||||
|
const annoteDls = window.document.querySelectorAll('dt[data-target-cell]');
|
||||||
|
for (const annoteDlNode of annoteDls) {
|
||||||
|
annoteDlNode.addEventListener('click', (event) => {
|
||||||
|
const clickedEl = event.target;
|
||||||
|
if (clickedEl !== selectedAnnoteEl) {
|
||||||
|
unselectCodeLines();
|
||||||
|
const activeEl = window.document.querySelector('dt[data-target-cell].code-annotation-active');
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.classList.remove('code-annotation-active');
|
||||||
|
}
|
||||||
|
selectCodeLines(clickedEl);
|
||||||
|
clickedEl.classList.add('code-annotation-active');
|
||||||
|
} else {
|
||||||
|
// Unselect the line
|
||||||
|
unselectCodeLines();
|
||||||
|
clickedEl.classList.remove('code-annotation-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const findCites = (el) => {
|
||||||
|
const parentEl = el.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const cites = parentEl.dataset.cites;
|
||||||
|
if (cites) {
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
cites: cites.split(' ')
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return findCites(el.parentElement)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var bibliorefs = window.document.querySelectorAll('a[role="doc-biblioref"]');
|
||||||
|
for (var i=0; i<bibliorefs.length; i++) {
|
||||||
|
const ref = bibliorefs[i];
|
||||||
|
const citeInfo = findCites(ref);
|
||||||
|
if (citeInfo) {
|
||||||
|
tippyHover(citeInfo.el, function() {
|
||||||
|
var popup = window.document.createElement('div');
|
||||||
|
citeInfo.cites.forEach(function(cite) {
|
||||||
|
var citeDiv = window.document.createElement('div');
|
||||||
|
citeDiv.classList.add('hanging-indent');
|
||||||
|
citeDiv.classList.add('csl-entry');
|
||||||
|
var biblioDiv = window.document.getElementById('ref-' + cite);
|
||||||
|
if (biblioDiv) {
|
||||||
|
citeDiv.innerHTML = biblioDiv.innerHTML;
|
||||||
|
}
|
||||||
|
popup.appendChild(citeDiv);
|
||||||
|
});
|
||||||
|
return popup.innerHTML;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div> <!-- /content -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</body></html>
|
||||||
48
doc/schema.md
Normal file
48
doc/schema.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Architecture UPnP/DLNA
|
||||||
|
|
||||||
|
Voici la séparation **définition** vs **runtime** :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class Device
|
||||||
|
class Service
|
||||||
|
class Action
|
||||||
|
class StateVariable
|
||||||
|
|
||||||
|
class Server
|
||||||
|
class DeviceInstance
|
||||||
|
class ServiceInstance
|
||||||
|
class StateVariableInstance
|
||||||
|
class ActionHandler
|
||||||
|
|
||||||
|
Device "1" --> "1..*" Service
|
||||||
|
Device "0..*" --> "0..*" Device
|
||||||
|
Service "0..*" --> "0..*" Action
|
||||||
|
Service "1..*" --> "1..*" StateVariable
|
||||||
|
|
||||||
|
Server "1" --> "1..*" DeviceInstance
|
||||||
|
DeviceInstance "1" --> "1..*" ServiceInstance
|
||||||
|
DeviceInstance "0..*" --> "0..*" DeviceInstance
|
||||||
|
ServiceInstance "1" --> "1..*" StateVariableInstance
|
||||||
|
ServiceInstance "0..*" --> "0..*" ActionHandler
|
||||||
|
|
||||||
|
DeviceInstance ..> Device : instantiates
|
||||||
|
ServiceInstance ..> Service : instantiates
|
||||||
|
StateVariableInstance ..> StateVariable : instantiates
|
||||||
|
ActionHandler ..> Action : handles
|
||||||
|
|
||||||
|
class Device:::deviceType
|
||||||
|
class Service:::deviceType
|
||||||
|
class Action:::deviceType
|
||||||
|
class StateVariable:::deviceType
|
||||||
|
|
||||||
|
class Server:::instanceType
|
||||||
|
class DeviceInstance:::instanceType
|
||||||
|
class ServiceInstance:::instanceType
|
||||||
|
class StateVariableInstance:::instanceType
|
||||||
|
class ActionHandler:::instanceType
|
||||||
|
|
||||||
|
classDef deviceType fill:#f9f,stroke:#333,stroke-width:1px;
|
||||||
|
classDef instanceType fill:#9f9,stroke:#333,stroke-width:1px;
|
||||||
|
```
|
||||||
|
|
||||||
12
doc/schema_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css
vendored
Normal file
12
doc/schema_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2078
doc/schema_files/libs/bootstrap/bootstrap-icons.css
vendored
Normal file
2078
doc/schema_files/libs/bootstrap/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
doc/schema_files/libs/bootstrap/bootstrap-icons.woff
Normal file
BIN
doc/schema_files/libs/bootstrap/bootstrap-icons.woff
Normal file
Binary file not shown.
7
doc/schema_files/libs/bootstrap/bootstrap.min.js
vendored
Normal file
7
doc/schema_files/libs/bootstrap/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
doc/schema_files/libs/clipboard/clipboard.min.js
vendored
Normal file
7
doc/schema_files/libs/clipboard/clipboard.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
doc/schema_files/libs/quarto-html/anchor.min.js
vendored
Normal file
9
doc/schema_files/libs/quarto-html/anchor.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
doc/schema_files/libs/quarto-html/popper.min.js
vendored
Normal file
6
doc/schema_files/libs/quarto-html/popper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,236 @@
|
|||||||
|
/* quarto syntax highlight colors */
|
||||||
|
:root {
|
||||||
|
--quarto-hl-ot-color: #003B4F;
|
||||||
|
--quarto-hl-at-color: #657422;
|
||||||
|
--quarto-hl-ss-color: #20794D;
|
||||||
|
--quarto-hl-an-color: #5E5E5E;
|
||||||
|
--quarto-hl-fu-color: #4758AB;
|
||||||
|
--quarto-hl-st-color: #20794D;
|
||||||
|
--quarto-hl-cf-color: #003B4F;
|
||||||
|
--quarto-hl-op-color: #5E5E5E;
|
||||||
|
--quarto-hl-er-color: #AD0000;
|
||||||
|
--quarto-hl-bn-color: #AD0000;
|
||||||
|
--quarto-hl-al-color: #AD0000;
|
||||||
|
--quarto-hl-va-color: #111111;
|
||||||
|
--quarto-hl-bu-color: inherit;
|
||||||
|
--quarto-hl-ex-color: inherit;
|
||||||
|
--quarto-hl-pp-color: #AD0000;
|
||||||
|
--quarto-hl-in-color: #5E5E5E;
|
||||||
|
--quarto-hl-vs-color: #20794D;
|
||||||
|
--quarto-hl-wa-color: #5E5E5E;
|
||||||
|
--quarto-hl-do-color: #5E5E5E;
|
||||||
|
--quarto-hl-im-color: #00769E;
|
||||||
|
--quarto-hl-ch-color: #20794D;
|
||||||
|
--quarto-hl-dt-color: #AD0000;
|
||||||
|
--quarto-hl-fl-color: #AD0000;
|
||||||
|
--quarto-hl-co-color: #5E5E5E;
|
||||||
|
--quarto-hl-cv-color: #5E5E5E;
|
||||||
|
--quarto-hl-cn-color: #8f5902;
|
||||||
|
--quarto-hl-sc-color: #5E5E5E;
|
||||||
|
--quarto-hl-dv-color: #AD0000;
|
||||||
|
--quarto-hl-kw-color: #003B4F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* other quarto variables */
|
||||||
|
:root {
|
||||||
|
--quarto-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* syntax highlight based on Pandoc's rules */
|
||||||
|
pre > code.sourceCode > span {
|
||||||
|
color: #003B4F;
|
||||||
|
}
|
||||||
|
|
||||||
|
code.sourceCode > span {
|
||||||
|
color: #003B4F;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sourceCode,
|
||||||
|
div.sourceCode pre.sourceCode {
|
||||||
|
color: #003B4F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normal */
|
||||||
|
code span {
|
||||||
|
color: #003B4F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert */
|
||||||
|
code span.al {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation */
|
||||||
|
code span.an {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attribute */
|
||||||
|
code span.at {
|
||||||
|
color: #657422;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BaseN */
|
||||||
|
code span.bn {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BuiltIn */
|
||||||
|
code span.bu {
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ControlFlow */
|
||||||
|
code span.cf {
|
||||||
|
color: #003B4F;
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Char */
|
||||||
|
code span.ch {
|
||||||
|
color: #20794D;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constant */
|
||||||
|
code span.cn {
|
||||||
|
color: #8f5902;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment */
|
||||||
|
code span.co {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CommentVar */
|
||||||
|
code span.cv {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Documentation */
|
||||||
|
code span.do {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DataType */
|
||||||
|
code span.dt {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DecVal */
|
||||||
|
code span.dv {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
code span.er {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extension */
|
||||||
|
code span.ex {
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Float */
|
||||||
|
code span.fl {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Function */
|
||||||
|
code span.fu {
|
||||||
|
color: #4758AB;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import */
|
||||||
|
code span.im {
|
||||||
|
color: #00769E;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Information */
|
||||||
|
code span.in {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyword */
|
||||||
|
code span.kw {
|
||||||
|
color: #003B4F;
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Operator */
|
||||||
|
code span.op {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Other */
|
||||||
|
code span.ot {
|
||||||
|
color: #003B4F;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preprocessor */
|
||||||
|
code span.pp {
|
||||||
|
color: #AD0000;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SpecialChar */
|
||||||
|
code span.sc {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SpecialString */
|
||||||
|
code span.ss {
|
||||||
|
color: #20794D;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* String */
|
||||||
|
code span.st {
|
||||||
|
color: #20794D;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variable */
|
||||||
|
code span.va {
|
||||||
|
color: #111111;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VerbatimString */
|
||||||
|
code span.vs {
|
||||||
|
color: #20794D;
|
||||||
|
font-style: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning */
|
||||||
|
code span.wa {
|
||||||
|
color: #5E5E5E;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prevent-inlining {
|
||||||
|
content: "</";
|
||||||
|
}
|
||||||
|
|
||||||
|
/*# sourceMappingURL=27d3d809615f7771493d095567f04340.css.map */
|
||||||
845
doc/schema_files/libs/quarto-html/quarto.js
Normal file
845
doc/schema_files/libs/quarto-html/quarto.js
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
import * as tabsets from "./tabsets/tabsets.js";
|
||||||
|
|
||||||
|
const sectionChanged = new CustomEvent("quarto-sectionChanged", {
|
||||||
|
detail: {},
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: false,
|
||||||
|
composed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutMarginEls = () => {
|
||||||
|
// Find any conflicting margin elements and add margins to the
|
||||||
|
// top to prevent overlap
|
||||||
|
const marginChildren = window.document.querySelectorAll(
|
||||||
|
".column-margin.column-container > *, .margin-caption, .aside"
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastBottom = 0;
|
||||||
|
for (const marginChild of marginChildren) {
|
||||||
|
if (marginChild.offsetParent !== null) {
|
||||||
|
// clear the top margin so we recompute it
|
||||||
|
marginChild.style.marginTop = null;
|
||||||
|
const top = marginChild.getBoundingClientRect().top + window.scrollY;
|
||||||
|
if (top < lastBottom) {
|
||||||
|
const marginChildStyle = window.getComputedStyle(marginChild);
|
||||||
|
const marginBottom = parseFloat(marginChildStyle["marginBottom"]);
|
||||||
|
const margin = lastBottom - top + marginBottom;
|
||||||
|
marginChild.style.marginTop = `${margin}px`;
|
||||||
|
}
|
||||||
|
const styles = window.getComputedStyle(marginChild);
|
||||||
|
const marginTop = parseFloat(styles["marginTop"]);
|
||||||
|
lastBottom = top + marginChild.getBoundingClientRect().height + marginTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.document.addEventListener("DOMContentLoaded", function (_event) {
|
||||||
|
// Recompute the position of margin elements anytime the body size changes
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
const resizeObserver = new window.ResizeObserver(
|
||||||
|
throttle(() => {
|
||||||
|
layoutMarginEls();
|
||||||
|
if (
|
||||||
|
window.document.body.getBoundingClientRect().width < 990 &&
|
||||||
|
isReaderMode()
|
||||||
|
) {
|
||||||
|
quartoToggleReader();
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
);
|
||||||
|
resizeObserver.observe(window.document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]');
|
||||||
|
const sidebarEl = window.document.getElementById("quarto-sidebar");
|
||||||
|
const leftTocEl = window.document.getElementById("quarto-sidebar-toc-left");
|
||||||
|
const marginSidebarEl = window.document.getElementById(
|
||||||
|
"quarto-margin-sidebar"
|
||||||
|
);
|
||||||
|
// function to determine whether the element has a previous sibling that is active
|
||||||
|
const prevSiblingIsActiveLink = (el) => {
|
||||||
|
const sibling = el.previousElementSibling;
|
||||||
|
if (sibling && sibling.tagName === "A") {
|
||||||
|
return sibling.classList.contains("active");
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// dispatch for htmlwidgets
|
||||||
|
// they use slideenter event to trigger resize
|
||||||
|
function fireSlideEnter() {
|
||||||
|
const event = window.document.createEvent("Event");
|
||||||
|
event.initEvent("slideenter", true, true);
|
||||||
|
window.document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]');
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
tab.addEventListener("shown.bs.tab", fireSlideEnter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// dispatch for shiny
|
||||||
|
// they use BS shown and hidden events to trigger rendering
|
||||||
|
function distpatchShinyEvents(previous, current) {
|
||||||
|
if (window.jQuery) {
|
||||||
|
if (previous) {
|
||||||
|
window.jQuery(previous).trigger("hidden");
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
window.jQuery(current).trigger("shown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tabby.js listener: Trigger event for htmlwidget and shiny
|
||||||
|
document.addEventListener(
|
||||||
|
"tabby",
|
||||||
|
function (event) {
|
||||||
|
fireSlideEnter();
|
||||||
|
distpatchShinyEvents(event.detail.previousTab, event.detail.tab);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track scrolling and mark TOC links as active
|
||||||
|
// get table of contents and sidebar (bail if we don't have at least one)
|
||||||
|
const tocLinks = tocEl
|
||||||
|
? [...tocEl.querySelectorAll("a[data-scroll-target]")]
|
||||||
|
: [];
|
||||||
|
const makeActive = (link) => tocLinks[link].classList.add("active");
|
||||||
|
const removeActive = (link) => tocLinks[link].classList.remove("active");
|
||||||
|
const removeAllActive = () =>
|
||||||
|
[...Array(tocLinks.length).keys()].forEach((link) => removeActive(link));
|
||||||
|
|
||||||
|
// activate the anchor for a section associated with this TOC entry
|
||||||
|
tocLinks.forEach((link) => {
|
||||||
|
link.addEventListener("click", () => {
|
||||||
|
if (link.href.indexOf("#") !== -1) {
|
||||||
|
const anchor = link.href.split("#")[1];
|
||||||
|
const heading = window.document.querySelector(
|
||||||
|
`[data-anchor-id="${anchor}"]`
|
||||||
|
);
|
||||||
|
if (heading) {
|
||||||
|
// Add the class
|
||||||
|
heading.classList.add("reveal-anchorjs-link");
|
||||||
|
|
||||||
|
// function to show the anchor
|
||||||
|
const handleMouseout = () => {
|
||||||
|
heading.classList.remove("reveal-anchorjs-link");
|
||||||
|
heading.removeEventListener("mouseout", handleMouseout);
|
||||||
|
};
|
||||||
|
|
||||||
|
// add a function to clear the anchor when the user mouses out of it
|
||||||
|
heading.addEventListener("mouseout", handleMouseout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = tocLinks.map((link) => {
|
||||||
|
const target = link.getAttribute("data-scroll-target");
|
||||||
|
if (target.startsWith("#")) {
|
||||||
|
return window.document.getElementById(decodeURI(`${target.slice(1)}`));
|
||||||
|
} else {
|
||||||
|
return window.document.querySelector(decodeURI(`${target}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionMargin = 200;
|
||||||
|
let currentActive = 0;
|
||||||
|
// track whether we've initialized state the first time
|
||||||
|
let init = false;
|
||||||
|
|
||||||
|
const updateActiveLink = () => {
|
||||||
|
// The index from bottom to top (e.g. reversed list)
|
||||||
|
let sectionIndex = -1;
|
||||||
|
if (
|
||||||
|
window.innerHeight + window.pageYOffset >=
|
||||||
|
window.document.body.offsetHeight
|
||||||
|
) {
|
||||||
|
// This is the no-scroll case where last section should be the active one
|
||||||
|
sectionIndex = 0;
|
||||||
|
} else {
|
||||||
|
// This finds the last section visible on screen that should be made active
|
||||||
|
sectionIndex = [...sections].reverse().findIndex((section) => {
|
||||||
|
if (section) {
|
||||||
|
return window.pageYOffset >= section.offsetTop - sectionMargin;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sectionIndex > -1) {
|
||||||
|
const current = sections.length - sectionIndex - 1;
|
||||||
|
if (current !== currentActive) {
|
||||||
|
removeAllActive();
|
||||||
|
currentActive = current;
|
||||||
|
makeActive(current);
|
||||||
|
if (init) {
|
||||||
|
window.dispatchEvent(sectionChanged);
|
||||||
|
}
|
||||||
|
init = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inHiddenRegion = (top, bottom, hiddenRegions) => {
|
||||||
|
for (const region of hiddenRegions) {
|
||||||
|
if (top <= region.bottom && bottom >= region.top) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorySelector = "header.quarto-title-block .quarto-category";
|
||||||
|
const activateCategories = (href) => {
|
||||||
|
// Find any categories
|
||||||
|
// Surround them with a link pointing back to:
|
||||||
|
// #category=Authoring
|
||||||
|
try {
|
||||||
|
const categoryEls = window.document.querySelectorAll(categorySelector);
|
||||||
|
for (const categoryEl of categoryEls) {
|
||||||
|
const categoryText = categoryEl.textContent;
|
||||||
|
if (categoryText) {
|
||||||
|
const link = `${href}#category=${encodeURIComponent(categoryText)}`;
|
||||||
|
const linkEl = window.document.createElement("a");
|
||||||
|
linkEl.setAttribute("href", link);
|
||||||
|
for (const child of categoryEl.childNodes) {
|
||||||
|
linkEl.append(child);
|
||||||
|
}
|
||||||
|
categoryEl.appendChild(linkEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function hasTitleCategories() {
|
||||||
|
return window.document.querySelector(categorySelector) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetRelativeUrl(url) {
|
||||||
|
const offset = getMeta("quarto:offset");
|
||||||
|
return offset ? offset + url : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetAbsoluteUrl(url) {
|
||||||
|
const offset = getMeta("quarto:offset");
|
||||||
|
const baseUrl = new URL(offset, window.location);
|
||||||
|
|
||||||
|
const projRelativeUrl = url.replace(baseUrl, "");
|
||||||
|
if (projRelativeUrl.startsWith("/")) {
|
||||||
|
return projRelativeUrl;
|
||||||
|
} else {
|
||||||
|
return "/" + projRelativeUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read a meta tag value
|
||||||
|
function getMeta(metaName) {
|
||||||
|
const metas = window.document.getElementsByTagName("meta");
|
||||||
|
for (let i = 0; i < metas.length; i++) {
|
||||||
|
if (metas[i].getAttribute("name") === metaName) {
|
||||||
|
return metas[i].getAttribute("content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAndActivateCategories() {
|
||||||
|
// Categories search with listing only use path without query
|
||||||
|
const currentPagePath = offsetAbsoluteUrl(
|
||||||
|
window.location.origin + window.location.pathname
|
||||||
|
);
|
||||||
|
const response = await fetch(offsetRelativeUrl("listings.json"));
|
||||||
|
if (response.status == 200) {
|
||||||
|
return response.json().then(function (listingPaths) {
|
||||||
|
const listingHrefs = [];
|
||||||
|
for (const listingPath of listingPaths) {
|
||||||
|
const pathWithoutLeadingSlash = listingPath.listing.substring(1);
|
||||||
|
for (const item of listingPath.items) {
|
||||||
|
const encodedItem = encodeURI(item);
|
||||||
|
if (
|
||||||
|
encodedItem === currentPagePath ||
|
||||||
|
encodedItem === currentPagePath + "index.html"
|
||||||
|
) {
|
||||||
|
// Resolve this path against the offset to be sure
|
||||||
|
// we already are using the correct path to the listing
|
||||||
|
// (this adjusts the listing urls to be rooted against
|
||||||
|
// whatever root the page is actually running against)
|
||||||
|
const relative = offsetRelativeUrl(pathWithoutLeadingSlash);
|
||||||
|
const baseUrl = window.location;
|
||||||
|
const resolvedPath = new URL(relative, baseUrl);
|
||||||
|
listingHrefs.push(resolvedPath.pathname);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the tree for a nearby linting and use that if we find one
|
||||||
|
const nearestListing = findNearestParentListing(
|
||||||
|
offsetAbsoluteUrl(window.location.pathname),
|
||||||
|
listingHrefs
|
||||||
|
);
|
||||||
|
if (nearestListing) {
|
||||||
|
activateCategories(nearestListing);
|
||||||
|
} else {
|
||||||
|
// See if the referrer is a listing page for this item
|
||||||
|
const referredRelativePath = offsetAbsoluteUrl(document.referrer);
|
||||||
|
const referrerListing = listingHrefs.find((listingHref) => {
|
||||||
|
const isListingReferrer =
|
||||||
|
listingHref === referredRelativePath ||
|
||||||
|
listingHref === referredRelativePath + "index.html";
|
||||||
|
return isListingReferrer;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (referrerListing) {
|
||||||
|
// Try to use the referrer if possible
|
||||||
|
activateCategories(referrerListing);
|
||||||
|
} else if (listingHrefs.length > 0) {
|
||||||
|
// Otherwise, just fall back to the first listing
|
||||||
|
activateCategories(listingHrefs[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasTitleCategories()) {
|
||||||
|
findAndActivateCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
const findNearestParentListing = (href, listingHrefs) => {
|
||||||
|
if (!href || !listingHrefs) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Look up the tree for a nearby linting and use that if we find one
|
||||||
|
const relativeParts = href.substring(1).split("/");
|
||||||
|
while (relativeParts.length > 0) {
|
||||||
|
const path = relativeParts.join("/");
|
||||||
|
for (const listingHref of listingHrefs) {
|
||||||
|
if (listingHref.startsWith(path)) {
|
||||||
|
return listingHref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relativeParts.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const manageSidebarVisiblity = (el, placeholderDescriptor) => {
|
||||||
|
let isVisible = true;
|
||||||
|
let elRect;
|
||||||
|
|
||||||
|
return (hiddenRegions) => {
|
||||||
|
if (el === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last element of the TOC
|
||||||
|
const lastChildEl = el.lastElementChild;
|
||||||
|
|
||||||
|
if (lastChildEl) {
|
||||||
|
// Converts the sidebar to a menu
|
||||||
|
const convertToMenu = () => {
|
||||||
|
for (const child of el.children) {
|
||||||
|
child.style.opacity = 0;
|
||||||
|
child.style.overflow = "hidden";
|
||||||
|
child.style.pointerEvents = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
nexttick(() => {
|
||||||
|
const toggleContainer = window.document.createElement("div");
|
||||||
|
toggleContainer.style.width = "100%";
|
||||||
|
toggleContainer.classList.add("zindex-over-content");
|
||||||
|
toggleContainer.classList.add("quarto-sidebar-toggle");
|
||||||
|
toggleContainer.classList.add("headroom-target"); // Marks this to be managed by headeroom
|
||||||
|
toggleContainer.id = placeholderDescriptor.id;
|
||||||
|
toggleContainer.style.position = "fixed";
|
||||||
|
|
||||||
|
const toggleIcon = window.document.createElement("i");
|
||||||
|
toggleIcon.classList.add("quarto-sidebar-toggle-icon");
|
||||||
|
toggleIcon.classList.add("bi");
|
||||||
|
toggleIcon.classList.add("bi-caret-down-fill");
|
||||||
|
|
||||||
|
const toggleTitle = window.document.createElement("div");
|
||||||
|
const titleEl = window.document.body.querySelector(
|
||||||
|
placeholderDescriptor.titleSelector
|
||||||
|
);
|
||||||
|
if (titleEl) {
|
||||||
|
toggleTitle.append(
|
||||||
|
titleEl.textContent || titleEl.innerText,
|
||||||
|
toggleIcon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toggleTitle.classList.add("zindex-over-content");
|
||||||
|
toggleTitle.classList.add("quarto-sidebar-toggle-title");
|
||||||
|
toggleContainer.append(toggleTitle);
|
||||||
|
|
||||||
|
const toggleContents = window.document.createElement("div");
|
||||||
|
toggleContents.classList = el.classList;
|
||||||
|
toggleContents.classList.add("zindex-over-content");
|
||||||
|
toggleContents.classList.add("quarto-sidebar-toggle-contents");
|
||||||
|
for (const child of el.children) {
|
||||||
|
if (child.id === "toc-title") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = child.cloneNode(true);
|
||||||
|
clone.style.opacity = 1;
|
||||||
|
clone.style.pointerEvents = null;
|
||||||
|
clone.style.display = null;
|
||||||
|
toggleContents.append(clone);
|
||||||
|
}
|
||||||
|
toggleContents.style.height = "0px";
|
||||||
|
const positionToggle = () => {
|
||||||
|
// position the element (top left of parent, same width as parent)
|
||||||
|
if (!elRect) {
|
||||||
|
elRect = el.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
toggleContainer.style.left = `${elRect.left}px`;
|
||||||
|
toggleContainer.style.top = `${elRect.top}px`;
|
||||||
|
toggleContainer.style.width = `${elRect.width}px`;
|
||||||
|
};
|
||||||
|
positionToggle();
|
||||||
|
|
||||||
|
toggleContainer.append(toggleContents);
|
||||||
|
el.parentElement.prepend(toggleContainer);
|
||||||
|
|
||||||
|
// Process clicks
|
||||||
|
let tocShowing = false;
|
||||||
|
// Allow the caller to control whether this is dismissed
|
||||||
|
// when it is clicked (e.g. sidebar navigation supports
|
||||||
|
// opening and closing the nav tree, so don't dismiss on click)
|
||||||
|
const clickEl = placeholderDescriptor.dismissOnClick
|
||||||
|
? toggleContainer
|
||||||
|
: toggleTitle;
|
||||||
|
|
||||||
|
const closeToggle = () => {
|
||||||
|
if (tocShowing) {
|
||||||
|
toggleContainer.classList.remove("expanded");
|
||||||
|
toggleContents.style.height = "0px";
|
||||||
|
tocShowing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get rid of any expanded toggle if the user scrolls
|
||||||
|
window.document.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
throttle(() => {
|
||||||
|
closeToggle();
|
||||||
|
}, 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle positioning of the toggle
|
||||||
|
window.addEventListener(
|
||||||
|
"resize",
|
||||||
|
throttle(() => {
|
||||||
|
elRect = undefined;
|
||||||
|
positionToggle();
|
||||||
|
}, 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
window.addEventListener("quarto-hrChanged", () => {
|
||||||
|
elRect = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process the click
|
||||||
|
clickEl.onclick = () => {
|
||||||
|
if (!tocShowing) {
|
||||||
|
toggleContainer.classList.add("expanded");
|
||||||
|
toggleContents.style.height = null;
|
||||||
|
tocShowing = true;
|
||||||
|
} else {
|
||||||
|
closeToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Converts a sidebar from a menu back to a sidebar
|
||||||
|
const convertToSidebar = () => {
|
||||||
|
for (const child of el.children) {
|
||||||
|
child.style.opacity = 1;
|
||||||
|
child.style.overflow = null;
|
||||||
|
child.style.pointerEvents = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderEl = window.document.getElementById(
|
||||||
|
placeholderDescriptor.id
|
||||||
|
);
|
||||||
|
if (placeholderEl) {
|
||||||
|
placeholderEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.remove("rollup");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isReaderMode()) {
|
||||||
|
convertToMenu();
|
||||||
|
isVisible = false;
|
||||||
|
} else {
|
||||||
|
// Find the top and bottom o the element that is being managed
|
||||||
|
const elTop = el.offsetTop;
|
||||||
|
const elBottom =
|
||||||
|
elTop + lastChildEl.offsetTop + lastChildEl.offsetHeight;
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
// If the element is current not visible reveal if there are
|
||||||
|
// no conflicts with overlay regions
|
||||||
|
if (!inHiddenRegion(elTop, elBottom, hiddenRegions)) {
|
||||||
|
convertToSidebar();
|
||||||
|
isVisible = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the element is visible, hide it if it conflicts with overlay regions
|
||||||
|
// and insert a placeholder toggle (or if we're in reader mode)
|
||||||
|
if (inHiddenRegion(elTop, elBottom, hiddenRegions)) {
|
||||||
|
convertToMenu();
|
||||||
|
isVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabEls = document.querySelectorAll('a[data-bs-toggle="tab"]');
|
||||||
|
for (const tabEl of tabEls) {
|
||||||
|
const id = tabEl.getAttribute("data-bs-target");
|
||||||
|
if (id) {
|
||||||
|
const columnEl = document.querySelector(
|
||||||
|
`${id} .column-margin, .tabset-margin-content`
|
||||||
|
);
|
||||||
|
if (columnEl)
|
||||||
|
tabEl.addEventListener("shown.bs.tab", function (event) {
|
||||||
|
const el = event.srcElement;
|
||||||
|
if (el) {
|
||||||
|
const visibleCls = `${el.id}-margin-content`;
|
||||||
|
// walk up until we find a parent tabset
|
||||||
|
let panelTabsetEl = el.parentElement;
|
||||||
|
while (panelTabsetEl) {
|
||||||
|
if (panelTabsetEl.classList.contains("panel-tabset")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
panelTabsetEl = panelTabsetEl.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelTabsetEl) {
|
||||||
|
const prevSib = panelTabsetEl.previousElementSibling;
|
||||||
|
if (
|
||||||
|
prevSib &&
|
||||||
|
prevSib.classList.contains("tabset-margin-container")
|
||||||
|
) {
|
||||||
|
const childNodes = prevSib.querySelectorAll(
|
||||||
|
".tabset-margin-content"
|
||||||
|
);
|
||||||
|
for (const childEl of childNodes) {
|
||||||
|
if (childEl.classList.contains(visibleCls)) {
|
||||||
|
childEl.classList.remove("collapse");
|
||||||
|
} else {
|
||||||
|
childEl.classList.add("collapse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutMarginEls();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manage the visibility of the toc and the sidebar
|
||||||
|
const marginScrollVisibility = manageSidebarVisiblity(marginSidebarEl, {
|
||||||
|
id: "quarto-toc-toggle",
|
||||||
|
titleSelector: "#toc-title",
|
||||||
|
dismissOnClick: true,
|
||||||
|
});
|
||||||
|
const sidebarScrollVisiblity = manageSidebarVisiblity(sidebarEl, {
|
||||||
|
id: "quarto-sidebarnav-toggle",
|
||||||
|
titleSelector: ".title",
|
||||||
|
dismissOnClick: false,
|
||||||
|
});
|
||||||
|
let tocLeftScrollVisibility;
|
||||||
|
if (leftTocEl) {
|
||||||
|
tocLeftScrollVisibility = manageSidebarVisiblity(leftTocEl, {
|
||||||
|
id: "quarto-lefttoc-toggle",
|
||||||
|
titleSelector: "#toc-title",
|
||||||
|
dismissOnClick: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first element that uses formatting in special columns
|
||||||
|
const conflictingEls = window.document.body.querySelectorAll(
|
||||||
|
'[class^="column-"], [class*=" column-"], aside, [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter all the possibly conflicting elements into ones
|
||||||
|
// the do conflict on the left or ride side
|
||||||
|
const arrConflictingEls = Array.from(conflictingEls);
|
||||||
|
const leftSideConflictEls = arrConflictingEls.filter((el) => {
|
||||||
|
if (el.tagName === "ASIDE") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Array.from(el.classList).find((className) => {
|
||||||
|
return (
|
||||||
|
className !== "column-body" &&
|
||||||
|
className.startsWith("column-") &&
|
||||||
|
!className.endsWith("right") &&
|
||||||
|
!className.endsWith("container") &&
|
||||||
|
className !== "column-margin"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const rightSideConflictEls = arrConflictingEls.filter((el) => {
|
||||||
|
if (el.tagName === "ASIDE") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMarginCaption = Array.from(el.classList).find((className) => {
|
||||||
|
return className == "margin-caption";
|
||||||
|
});
|
||||||
|
if (hasMarginCaption) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(el.classList).find((className) => {
|
||||||
|
return (
|
||||||
|
className !== "column-body" &&
|
||||||
|
!className.endsWith("container") &&
|
||||||
|
className.startsWith("column-") &&
|
||||||
|
!className.endsWith("left")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const kOverlapPaddingSize = 10;
|
||||||
|
function toRegions(els) {
|
||||||
|
return els.map((el) => {
|
||||||
|
const boundRect = el.getBoundingClientRect();
|
||||||
|
const top =
|
||||||
|
boundRect.top +
|
||||||
|
document.documentElement.scrollTop -
|
||||||
|
kOverlapPaddingSize;
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
bottom: top + el.scrollHeight + 2 * kOverlapPaddingSize,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasObserved = false;
|
||||||
|
const visibleItemObserver = (els) => {
|
||||||
|
let visibleElements = [...els];
|
||||||
|
const intersectionObserver = new IntersectionObserver(
|
||||||
|
(entries, _observer) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (visibleElements.indexOf(entry.target) === -1) {
|
||||||
|
visibleElements.push(entry.target);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
visibleElements = visibleElements.filter((visibleEntry) => {
|
||||||
|
return visibleEntry !== entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasObserved) {
|
||||||
|
hideOverlappedSidebars();
|
||||||
|
}
|
||||||
|
hasObserved = true;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
els.forEach((el) => {
|
||||||
|
intersectionObserver.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getVisibleEntries: () => {
|
||||||
|
return visibleElements;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rightElementObserver = visibleItemObserver(rightSideConflictEls);
|
||||||
|
const leftElementObserver = visibleItemObserver(leftSideConflictEls);
|
||||||
|
|
||||||
|
const hideOverlappedSidebars = () => {
|
||||||
|
marginScrollVisibility(toRegions(rightElementObserver.getVisibleEntries()));
|
||||||
|
sidebarScrollVisiblity(toRegions(leftElementObserver.getVisibleEntries()));
|
||||||
|
if (tocLeftScrollVisibility) {
|
||||||
|
tocLeftScrollVisibility(
|
||||||
|
toRegions(leftElementObserver.getVisibleEntries())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.quartoToggleReader = () => {
|
||||||
|
// Applies a slow class (or removes it)
|
||||||
|
// to update the transition speed
|
||||||
|
const slowTransition = (slow) => {
|
||||||
|
const manageTransition = (id, slow) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
if (slow) {
|
||||||
|
el.classList.add("slow");
|
||||||
|
} else {
|
||||||
|
el.classList.remove("slow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
manageTransition("TOC", slow);
|
||||||
|
manageTransition("quarto-sidebar", slow);
|
||||||
|
};
|
||||||
|
const readerMode = !isReaderMode();
|
||||||
|
setReaderModeValue(readerMode);
|
||||||
|
|
||||||
|
// If we're entering reader mode, slow the transition
|
||||||
|
if (readerMode) {
|
||||||
|
slowTransition(readerMode);
|
||||||
|
}
|
||||||
|
highlightReaderToggle(readerMode);
|
||||||
|
hideOverlappedSidebars();
|
||||||
|
|
||||||
|
// If we're exiting reader mode, restore the non-slow transition
|
||||||
|
if (!readerMode) {
|
||||||
|
slowTransition(!readerMode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightReaderToggle = (readerMode) => {
|
||||||
|
const els = document.querySelectorAll(".quarto-reader-toggle");
|
||||||
|
if (els) {
|
||||||
|
els.forEach((el) => {
|
||||||
|
if (readerMode) {
|
||||||
|
el.classList.add("reader");
|
||||||
|
} else {
|
||||||
|
el.classList.remove("reader");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReaderModeValue = (val) => {
|
||||||
|
if (window.location.protocol !== "file:") {
|
||||||
|
window.localStorage.setItem("quarto-reader-mode", val);
|
||||||
|
} else {
|
||||||
|
localReaderMode = val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReaderMode = () => {
|
||||||
|
if (window.location.protocol !== "file:") {
|
||||||
|
return window.localStorage.getItem("quarto-reader-mode") === "true";
|
||||||
|
} else {
|
||||||
|
return localReaderMode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let localReaderMode = null;
|
||||||
|
|
||||||
|
const tocOpenDepthStr = tocEl?.getAttribute("data-toc-expanded");
|
||||||
|
const tocOpenDepth = tocOpenDepthStr ? Number(tocOpenDepthStr) : 1;
|
||||||
|
|
||||||
|
// Walk the TOC and collapse/expand nodes
|
||||||
|
// Nodes are expanded if:
|
||||||
|
// - they are top level
|
||||||
|
// - they have children that are 'active' links
|
||||||
|
// - they are directly below an link that is 'active'
|
||||||
|
const walk = (el, depth) => {
|
||||||
|
// Tick depth when we enter a UL
|
||||||
|
if (el.tagName === "UL") {
|
||||||
|
depth = depth + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It this is active link
|
||||||
|
let isActiveNode = false;
|
||||||
|
if (el.tagName === "A" && el.classList.contains("active")) {
|
||||||
|
isActiveNode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// See if there is an active child to this element
|
||||||
|
let hasActiveChild = false;
|
||||||
|
for (const child of el.children) {
|
||||||
|
hasActiveChild = walk(child, depth) || hasActiveChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the collapse state if this is an UL
|
||||||
|
if (el.tagName === "UL") {
|
||||||
|
if (tocOpenDepth === -1 && depth > 1) {
|
||||||
|
// toc-expand: false
|
||||||
|
el.classList.add("collapse");
|
||||||
|
} else if (
|
||||||
|
depth <= tocOpenDepth ||
|
||||||
|
hasActiveChild ||
|
||||||
|
prevSiblingIsActiveLink(el)
|
||||||
|
) {
|
||||||
|
el.classList.remove("collapse");
|
||||||
|
} else {
|
||||||
|
el.classList.add("collapse");
|
||||||
|
}
|
||||||
|
|
||||||
|
// untick depth when we leave a UL
|
||||||
|
depth = depth - 1;
|
||||||
|
}
|
||||||
|
return hasActiveChild || isActiveNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// walk the TOC and expand / collapse any items that should be shown
|
||||||
|
if (tocEl) {
|
||||||
|
updateActiveLink();
|
||||||
|
walk(tocEl, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle the scroll event and walk peridiocally
|
||||||
|
window.document.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
throttle(() => {
|
||||||
|
if (tocEl) {
|
||||||
|
updateActiveLink();
|
||||||
|
walk(tocEl, 0);
|
||||||
|
}
|
||||||
|
if (!isReaderMode()) {
|
||||||
|
hideOverlappedSidebars();
|
||||||
|
}
|
||||||
|
}, 5)
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
"resize",
|
||||||
|
throttle(() => {
|
||||||
|
if (tocEl) {
|
||||||
|
updateActiveLink();
|
||||||
|
walk(tocEl, 0);
|
||||||
|
}
|
||||||
|
if (!isReaderMode()) {
|
||||||
|
hideOverlappedSidebars();
|
||||||
|
}
|
||||||
|
}, 10)
|
||||||
|
);
|
||||||
|
hideOverlappedSidebars();
|
||||||
|
highlightReaderToggle(isReaderMode());
|
||||||
|
});
|
||||||
|
|
||||||
|
tabsets.init();
|
||||||
|
|
||||||
|
function throttle(func, wait) {
|
||||||
|
let waiting = false;
|
||||||
|
return function () {
|
||||||
|
if (!waiting) {
|
||||||
|
func.apply(this, arguments);
|
||||||
|
waiting = true;
|
||||||
|
setTimeout(function () {
|
||||||
|
waiting = false;
|
||||||
|
}, wait);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nexttick(func) {
|
||||||
|
return setTimeout(func, 0);
|
||||||
|
}
|
||||||
95
doc/schema_files/libs/quarto-html/tabsets/tabsets.js
Normal file
95
doc/schema_files/libs/quarto-html/tabsets/tabsets.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// grouped tabsets
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
window.addEventListener("pageshow", (_event) => {
|
||||||
|
function getTabSettings() {
|
||||||
|
const data = localStorage.getItem("quarto-persistent-tabsets-data");
|
||||||
|
if (!data) {
|
||||||
|
localStorage.setItem("quarto-persistent-tabsets-data", "{}");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTabSettings(data) {
|
||||||
|
localStorage.setItem(
|
||||||
|
"quarto-persistent-tabsets-data",
|
||||||
|
JSON.stringify(data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTabState(groupName, groupValue) {
|
||||||
|
const data = getTabSettings();
|
||||||
|
data[groupName] = groupValue;
|
||||||
|
setTabSettings(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTab(tab, active) {
|
||||||
|
const tabPanelId = tab.getAttribute("aria-controls");
|
||||||
|
const tabPanel = document.getElementById(tabPanelId);
|
||||||
|
if (active) {
|
||||||
|
tab.classList.add("active");
|
||||||
|
tabPanel.classList.add("active");
|
||||||
|
} else {
|
||||||
|
tab.classList.remove("active");
|
||||||
|
tabPanel.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(selectedGroup, selectorsToSync) {
|
||||||
|
for (const [thisGroup, tabs] of Object.entries(selectorsToSync)) {
|
||||||
|
const active = selectedGroup === thisGroup;
|
||||||
|
for (const tab of tabs) {
|
||||||
|
toggleTab(tab, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSelectorsToSyncByLanguage() {
|
||||||
|
const result = {};
|
||||||
|
const tabs = Array.from(
|
||||||
|
document.querySelectorAll(`div[data-group] a[id^='tabset-']`)
|
||||||
|
);
|
||||||
|
for (const item of tabs) {
|
||||||
|
const div = item.parentElement.parentElement.parentElement;
|
||||||
|
const group = div.getAttribute("data-group");
|
||||||
|
if (!result[group]) {
|
||||||
|
result[group] = {};
|
||||||
|
}
|
||||||
|
const selectorsToSync = result[group];
|
||||||
|
const value = item.innerHTML;
|
||||||
|
if (!selectorsToSync[value]) {
|
||||||
|
selectorsToSync[value] = [];
|
||||||
|
}
|
||||||
|
selectorsToSync[value].push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSelectorSync() {
|
||||||
|
const selectorsToSync = findSelectorsToSyncByLanguage();
|
||||||
|
Object.entries(selectorsToSync).forEach(([group, tabSetsByValue]) => {
|
||||||
|
Object.entries(tabSetsByValue).forEach(([value, items]) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
item.addEventListener("click", (_event) => {
|
||||||
|
setTabState(group, value);
|
||||||
|
toggleAll(value, selectorsToSync[group]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return selectorsToSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectorsToSync = setupSelectorSync();
|
||||||
|
for (const [group, selectedName] of Object.entries(getTabSettings())) {
|
||||||
|
const selectors = selectorsToSync[group];
|
||||||
|
// it's possible that stale state gives us empty selections, so we explicitly check here.
|
||||||
|
if (selectors) {
|
||||||
|
toggleAll(selectedName, selectors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
1
doc/schema_files/libs/quarto-html/tippy.css
Normal file
1
doc/schema_files/libs/quarto-html/tippy.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}
|
||||||
2
doc/schema_files/libs/quarto-html/tippy.umd.min.js
vendored
Normal file
2
doc/schema_files/libs/quarto-html/tippy.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
131
doc/urls.md
Normal file
131
doc/urls.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
Oui. Pour un serveur UPnP/HTTP, l’URL de chaque device et service doit être **stable, unique et hiérarchique**, tout en restant simple à mapper depuis l’IP du serveur. Voici un schéma raisonnable :
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. Base URL du serveur
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.0.138:1900/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Devices
|
||||||
|
|
||||||
|
Chaque device a un **ID unique** ou un **nom** et un chemin dédié.
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```
|
||||||
|
/device/<device-type>/<device-id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples concrets :
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/
|
||||||
|
http://192.168.0.138:1900/device/LightController/1/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Services d’un device
|
||||||
|
|
||||||
|
Chaque device héberge un ou plusieurs services, accessibles sous son chemin :
|
||||||
|
|
||||||
|
```
|
||||||
|
/device/<device-type>/<device-id>/service/<service-type>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/service/RenderingControl
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/service/AVTransport
|
||||||
|
http://192.168.0.138:1900/device/LightController/1/service/OnOff
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Points spécifiques (facultatif)
|
||||||
|
|
||||||
|
* **Description XML (device/service)** :
|
||||||
|
|
||||||
|
```
|
||||||
|
/device/<device-type>/<device-id>/desc.xml
|
||||||
|
/device/<device-type>/<device-id>/service/<service-type>.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Action SOAP endpoint** :
|
||||||
|
|
||||||
|
```
|
||||||
|
/device/<device-type>/<device-id>/service/<service-type>/control
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Event subscription (GENA)** :
|
||||||
|
|
||||||
|
```
|
||||||
|
/device/<device-type>/<device-id>/service/<service-type>/eventSub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Exemple complet pour MusicRenderer
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/desc.xml
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/service/RenderingControl/control
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/service/RenderingControl/eventSub
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/service/AVTransport/control
|
||||||
|
http://192.168.0.138:1900/device/MusicRenderer/1/service/AVTransport/eventSub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
✅ Avantages :
|
||||||
|
|
||||||
|
* Hierarchique → facile à mapper et maintenir.
|
||||||
|
* Unique → pas de collision entre devices/services.
|
||||||
|
* Compatible avec SSDP/UPnP → tu peux construire le `device description URL` et le `service control URL` facilement.
|
||||||
|
|
||||||
|
Voici un exemple de schéma hiérarchique en **Mermaid** pour ton serveur UPnP avec plusieurs devices et services :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Server["UPnP Server<br>http://192.168.0.138:1900"]
|
||||||
|
|
||||||
|
Server --> MR["Device: MusicRenderer 1<br>http://192.168.0.138:1900/device/MusicRenderer/1/"]
|
||||||
|
Server --> LC["Device: LightController 1<br>http://192.168.0.138:1900/device/LightController/1/"]
|
||||||
|
|
||||||
|
%% MusicRenderer Services
|
||||||
|
MR --> MR_DESC["Description XML<br>http://192.168.0.138:1900/device/MusicRenderer/1/desc.xml"]
|
||||||
|
MR --> MR_RC["Service: RenderingControl"]
|
||||||
|
MR --> MR_AV["Service: AVTransport"]
|
||||||
|
|
||||||
|
MR_RC --> MR_RC_CONTROL["Control<br>http://192.168.0.138:1900/device/MusicRenderer/1/service/RenderingControl/control"]
|
||||||
|
MR_RC --> MR_RC_EVENT["EventSub<br>http://192.168.0.138:1900/device/MusicRenderer/1/service/RenderingControl/eventSub"]
|
||||||
|
|
||||||
|
MR_AV --> MR_AV_CONTROL["Control<br>http://192.168.0.138:1900/device/MusicRenderer/1/service/AVTransport/control"]
|
||||||
|
MR_AV --> MR_AV_EVENT["EventSub<br>http://192.168.0.138:1900/device/MusicRenderer/1/service/AVTransport/eventSub"]
|
||||||
|
|
||||||
|
%% LightController Services
|
||||||
|
LC --> LC_DESC["Description XML<br>http://192.168.0.138:1900/device/LightController/1/desc.xml"]
|
||||||
|
LC --> LC_ONOFF["Service: OnOff"]
|
||||||
|
|
||||||
|
LC_ONOFF --> LC_ONOFF_CONTROL["Control<br>http://192.168.0.138:1900/device/LightController/1/service/OnOff/control"]
|
||||||
|
LC_ONOFF --> LC_ONOFF_EVENT["EventSub<br>http://192.168.0.138:1900/device/LightController/1/service/OnOff/eventSub"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Explication
|
||||||
|
|
||||||
|
* `Server` → base URL du serveur.
|
||||||
|
* Chaque `Device` a son propre nœud et un fichier de description XML (`desc.xml`).
|
||||||
|
* Chaque `Service` a deux endpoints principaux :
|
||||||
|
|
||||||
|
* `control` → pour les actions SOAP.
|
||||||
|
* `eventSub` → pour les abonnements aux événements (GENA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
62
example_spcd/mediarenderer.xml
Normal file
62
example_spcd/mediarenderer.xml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||||
|
<friendlyName>My UPnP MediaRenderer</friendlyName>
|
||||||
|
<manufacturer>Example Corp</manufacturer>
|
||||||
|
<manufacturerURL>https://www.example.com</manufacturerURL>
|
||||||
|
<modelDescription>Example UPnP AV Renderer</modelDescription>
|
||||||
|
<modelName>ExampleRenderer</modelName>
|
||||||
|
<modelNumber>1.0</modelNumber>
|
||||||
|
<modelURL>https://www.example.com/renderer</modelURL>
|
||||||
|
<serialNumber>12345678</serialNumber>
|
||||||
|
<UDN>uuid:12345678-9abc-def0-1234-56789abcdef0</UDN>
|
||||||
|
<presentationURL>/</presentationURL>
|
||||||
|
|
||||||
|
<!-- Icône du périphérique -->
|
||||||
|
<iconList>
|
||||||
|
<icon>
|
||||||
|
<mimetype>image/png</mimetype>
|
||||||
|
<width>48</width>
|
||||||
|
<height>48</height>
|
||||||
|
<depth>24</depth>
|
||||||
|
<url>/icons/icon48.png</url>
|
||||||
|
</icon>
|
||||||
|
</iconList>
|
||||||
|
|
||||||
|
<!-- Liste des services -->
|
||||||
|
<serviceList>
|
||||||
|
<!-- RenderingControl -->
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
|
||||||
|
<SCPDURL>/service/RenderingControl/desc.xml</SCPDURL>
|
||||||
|
<controlURL>/service/RenderingControl/control</controlURL>
|
||||||
|
<eventSubURL>/service/RenderingControl/event</eventSubURL>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- ConnectionManager -->
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
|
||||||
|
<SCPDURL>/service/ConnectionManager/desc.xml</SCPDURL>
|
||||||
|
<controlURL>/service/ConnectionManager/control</controlURL>
|
||||||
|
<eventSubURL>/service/ConnectionManager/event</eventSubURL>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- AVTransport -->
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
|
||||||
|
<SCPDURL>/service/AVTransport/desc.xml</SCPDURL>
|
||||||
|
<controlURL>/service/AVTransport/control</controlURL>
|
||||||
|
<eventSubURL>/service/AVTransport/event</eventSubURL>
|
||||||
|
</service>
|
||||||
|
</serviceList>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
105
example_spcd/mediarenderer_AVTransport.xml
Normal file
105
example_spcd/mediarenderer_AVTransport.xml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
|
||||||
|
<actionList>
|
||||||
|
<!-- Exemples d’actions obligatoires -->
|
||||||
|
<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>
|
||||||
|
<!-- Variables d’état réelles -->
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>TransportState</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>STOPPED</allowedValue>
|
||||||
|
<allowedValue>PLAYING</allowedValue>
|
||||||
|
<allowedValue>TRANSITIONING</allowedValue>
|
||||||
|
<allowedValue>PAUSED_PLAYBACK</allowedValue>
|
||||||
|
<allowedValue>PAUSED_RECORDING</allowedValue>
|
||||||
|
<allowedValue>RECORDING</allowedValue>
|
||||||
|
<allowedValue>NO_MEDIA_PRESENT</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>TransportPlaySpeed</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>1</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
<defaultValue>1</defaultValue>
|
||||||
|
</stateVariable>
|
||||||
|
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>AVTransportURI</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>AVTransportURIMetaData</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
|
||||||
|
<!-- Variables A_ARG_TYPE (argument types) -->
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_InstanceID</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_PlaySpeed</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
</serviceStateTable>
|
||||||
|
</scpd>
|
||||||
187
example_spcd/mediarenderer_ConnectionManager.xml
Normal file
187
example_spcd/mediarenderer_ConnectionManager.xml
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
<name>PrepareForConnection</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>RemoteProtocolInfo</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionManager</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>PeerConnectionManager</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>PeerConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Direction</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Direction</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>AVTransportID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>AVTransportID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>RcsID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>RcsID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
<name>ConnectionComplete</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
<name>GetCurrentConnectionIDs</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionIDs</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
<name>GetCurrentConnectionInfo</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>RcsID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>RcsID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>AVTransportID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>AVTransportID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>ProtocolInfo</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionManager</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>PeerConnectionManager</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>PeerConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Direction</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>Direction</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Status</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>Status</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>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>CurrentConnectionIDs</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>AVTransportID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>RcsID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>PeerConnectionManager</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>PeerConnectionID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>Direction</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>Input</allowedValue>
|
||||||
|
<allowedValue>Output</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>Status</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>OK</allowedValue>
|
||||||
|
<allowedValue>ContentFormatMismatch</allowedValue>
|
||||||
|
<allowedValue>InsufficientBandwidth</allowedValue>
|
||||||
|
<allowedValue>UnreliableChannel</allowedValue>
|
||||||
|
<allowedValue>Unknown</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
</serviceStateTable>
|
||||||
|
</scpd>
|
||||||
123
example_spcd/mediarenderer_RenderingControl.xml
Normal file
123
example_spcd/mediarenderer_RenderingControl.xml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
|
||||||
|
<actionList>
|
||||||
|
<!-- SetVolume -->
|
||||||
|
<action>
|
||||||
|
<name>SetVolume</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>InstanceID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>InstanceID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Channel</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Channel</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>DesiredVolume</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Volume</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<!-- GetVolume -->
|
||||||
|
<action>
|
||||||
|
<name>GetVolume</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>InstanceID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>InstanceID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Channel</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Channel</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>CurrentVolume</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>Volume</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<!-- SetMute -->
|
||||||
|
<action>
|
||||||
|
<name>SetMute</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>InstanceID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>InstanceID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Channel</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Channel</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>DesiredMute</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Mute</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<!-- GetMute -->
|
||||||
|
<action>
|
||||||
|
<name>GetMute</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>InstanceID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>InstanceID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Channel</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>Channel</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>CurrentMute</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>Mute</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
</actionList>
|
||||||
|
|
||||||
|
<serviceStateTable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>InstanceID</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>Channel</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>Master</allowedValue>
|
||||||
|
<allowedValue>LF</allowedValue>
|
||||||
|
<allowedValue>RF</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>Volume</name>
|
||||||
|
<dataType>ui2</dataType>
|
||||||
|
<defaultValue>50</defaultValue>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>Mute</name>
|
||||||
|
<dataType>boolean</dataType>
|
||||||
|
<defaultValue>0</defaultValue>
|
||||||
|
</stateVariable>
|
||||||
|
</serviceStateTable>
|
||||||
|
</scpd>
|
||||||
27
fileutils/iswritable.go
Normal file
27
fileutils/iswritable.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package fileutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsWriteable(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
// File exists, check owner write permission
|
||||||
|
return info.Mode().Perm()&0200 != 0
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// File does not exist, check if parent directory is writable
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "." // fallback
|
||||||
|
}
|
||||||
|
dirInfo, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return dirInfo.IsDir() && dirInfo.Mode().Perm()&0200 != 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@@ -9,4 +9,7 @@ require (
|
|||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.32.0 // indirect
|
require (
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
17
netutils/ip_detect.go
Normal file
17
netutils/ip_detect.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package netutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fonction helper (remplace votre netutils.GuessLocalIP)
|
||||||
|
func GuessLocalIP() (string, error) {
|
||||||
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
|
if err != nil {
|
||||||
|
return "127.0.0.1", nil
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
||||||
|
return localAddr.IP.String(), nil
|
||||||
|
}
|
||||||
49
netutils/list_all_ip.go
Normal file
49
netutils/list_all_ip.go
Normal 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
|
||||||
|
}
|
||||||
367
upnp/config.go
Normal file
367
upnp/config.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/fileutils"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed pmomusic.yaml
|
||||||
|
var defaultConfig []byte
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
path string
|
||||||
|
mutex sync.Mutex
|
||||||
|
config map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _CONFIG *Config
|
||||||
|
|
||||||
|
const envConfigFile = "PMOMUSIC_CONFIG"
|
||||||
|
const envPrefix = "PMOMUSIC_CONFIG__"
|
||||||
|
|
||||||
|
// LoadConfig loads a configuration file from the given path or a default
|
||||||
|
// location.
|
||||||
|
//
|
||||||
|
// It prioritizes paths in this order:
|
||||||
|
// - the provided path,
|
||||||
|
// - the file specified by the environment variable PMOMUSIC_CONFIG
|
||||||
|
// - the .pmomusic.yml file in the current directory
|
||||||
|
// - the .pmomusic.yml file in the user's home directory, and . If no path is found
|
||||||
|
//
|
||||||
|
// or it fails to read any of these files, it falls back on a default
|
||||||
|
// configuration.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - path string: The path to the configuration file. If this
|
||||||
|
// parameter is empty, the function will look for the configuration in the
|
||||||
|
// current user's home directory and an environment variable.
|
||||||
|
//
|
||||||
|
// Returns: 1) Config: A struct containing the loaded configuration data.
|
||||||
|
//
|
||||||
|
// Side Effects:
|
||||||
|
//
|
||||||
|
// - This function reads from disk, logs informational messages, and may also
|
||||||
|
// panic if there are issues unmarshalling the YAML config file.
|
||||||
|
//
|
||||||
|
// Errors:
|
||||||
|
//
|
||||||
|
// - The function will log a warning message and continue with a default
|
||||||
|
// configuration if it fails to read or unmarshal any of the files. It does not
|
||||||
|
// return an error in this case, as returning errors from within deferred
|
||||||
|
// functions can cause unexpected behavior.
|
||||||
|
//
|
||||||
|
// - If there's an issue reading or unmarshalling the YAML file, the function
|
||||||
|
// will panic as this is a fatal condition that should be addressed immediately.
|
||||||
|
//
|
||||||
|
// Edge Cases:
|
||||||
|
//
|
||||||
|
// - This function does not handle race conditions where the
|
||||||
|
// configuration file could change between when it checks if the path is empty
|
||||||
|
// and when it attempts to read from it. If the file changes in that time, an
|
||||||
|
// error will occur.
|
||||||
|
//
|
||||||
|
// - This function assumes the YAML config files are formatted correctly. If
|
||||||
|
// they're not, unmarshalling them into a struct may result in unexpected
|
||||||
|
// behavior or errors.
|
||||||
|
//
|
||||||
|
// Usage: ``` go cfg := LoadConfig("/path/to/config") fmt.Println(cfg) ````
|
||||||
|
func LoadConfig(filename string) *Config {
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
var cfg = &Config{}
|
||||||
|
|
||||||
|
cfg.mutex.Lock()
|
||||||
|
defer cfg.mutex.Unlock()
|
||||||
|
|
||||||
|
path := filename
|
||||||
|
|
||||||
|
if path != "" {
|
||||||
|
log.Infof("✅ Trying to load config %s", path)
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("❌ cannot read config file %s", path)
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
path = os.Getenv(envConfigFile)
|
||||||
|
if path != "" {
|
||||||
|
log.Infof("✅ Trying to load config specified in env var %s", envConfigFile)
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("❌ cannot read config file %s specified in env var %s", path, envConfigFile)
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
path = ".pmomusic.yml"
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
log.Infof("✅ Trying to load config file %s/.pmomusic.yml", dir)
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("❌ I cannot read config file %s/.pmomusic.yml", dir)
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
path = getHomeYmlPath()
|
||||||
|
log.Infof("✅ Trying to load config file from user's home %s", path)
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("❌ I cannot read config file %s", path)
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
log.Infof("✅ Using default embeded config")
|
||||||
|
data = defaultConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, &cfg.config); err != nil {
|
||||||
|
log.Panicf("invalid YAML config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.config = lowerKeysMap(cfg.config)
|
||||||
|
|
||||||
|
applyEnvOverrides(cfg)
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
switch {
|
||||||
|
case filename != "" && fileutils.IsWriteable(filename):
|
||||||
|
path = filename
|
||||||
|
case os.Getenv(envConfigFile) != "" && fileutils.IsWriteable(os.Getenv(envConfigFile)):
|
||||||
|
path = os.Getenv(envConfigFile)
|
||||||
|
case fileutils.IsWriteable(".pmomusic.yml"):
|
||||||
|
path = ".pmomusic.yml"
|
||||||
|
case fileutils.IsWriteable(getHomeYmlPath()):
|
||||||
|
path = getHomeYmlPath()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !fileutils.IsWriteable(path) {
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
log.Panic("I cannot find a place to store config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("✅ Config file will be stored in %s", path)
|
||||||
|
|
||||||
|
cfg.path = path
|
||||||
|
cfg.mutex.Unlock()
|
||||||
|
cfg.Save()
|
||||||
|
cfg.mutex.Lock()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Save() error {
|
||||||
|
cfg.mutex.Lock()
|
||||||
|
defer cfg.mutex.Unlock()
|
||||||
|
|
||||||
|
cfg.config = lowerKeysMap(cfg.config)
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(cfg.config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(cfg.path, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) SetValue(path []string, value interface{}) {
|
||||||
|
cfg.setValue(path, value)
|
||||||
|
cfg.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) GetValue(path []string) (interface{}, error) {
|
||||||
|
cfg.mutex.Lock()
|
||||||
|
defer cfg.mutex.Unlock()
|
||||||
|
|
||||||
|
current := cfg.config
|
||||||
|
for i, key := range path {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
|
||||||
|
next, ok := current[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("path %s does not exist", strings.Join(path[:i+1], "."))
|
||||||
|
}
|
||||||
|
if i < len(path)-1 {
|
||||||
|
current, ok = next.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("path %s is not a Config", strings.Join(path[:i+1], "."))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("path %s does not exist", strings.Join(path[:], "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// overrideConfig sets a value in a nested map[string]interface{} at the given path.
|
||||||
|
func (cfg *Config) setValue(path []string, value interface{}) {
|
||||||
|
cfg.mutex.Lock()
|
||||||
|
defer cfg.mutex.Unlock()
|
||||||
|
|
||||||
|
current := cfg.config
|
||||||
|
for i, key := range path {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
if i == len(path)-1 {
|
||||||
|
current[key] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// ensure intermediate maps exist
|
||||||
|
if _, ok := current[key]; !ok {
|
||||||
|
current[key] = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
next, ok := current[key].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
// If the path conflicts with a non-object, overwrite it
|
||||||
|
next = make(map[string]interface{})
|
||||||
|
current[key] = next
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHomeYmlPath constructs and returns the path to the home directory of the
|
||||||
|
// current user followed by ".pmomusic.yml".
|
||||||
|
//
|
||||||
|
// This function does not take any parameters but it relies on the
|
||||||
|
// `user.Current()` function, which can return an error if it fails to determine
|
||||||
|
// the current user or their home directory. In such cases, a description of the
|
||||||
|
// error is printed to standard output and the empty string is returned.
|
||||||
|
//
|
||||||
|
// The function returns a string representing the path to the file in the
|
||||||
|
// following format: "$HOME/.pmomusic.yml". It does not return an error value
|
||||||
|
// since no errors are expected to occur during normal operation.
|
||||||
|
//
|
||||||
|
// Side Effects: None. This is a pure function that only depends on input and
|
||||||
|
// produces output without changing any state or causing side effects, except
|
||||||
|
// for the printing of potential error messages.
|
||||||
|
//
|
||||||
|
// Edge Cases: If `user.Current()` fails to determine the current user's home
|
||||||
|
// directory or the current user, an empty string is returned and a description
|
||||||
|
// of the error is printed.
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// fmt.Println(getHomeYmlPath())
|
||||||
|
//
|
||||||
|
// This will print something like "/Users/username/.pmomusic.yml" on macOS or Linux, or "C:\Users\Username\" on Windows if the current user's home directory is "C:\Users\Username".
|
||||||
|
func getHomeYmlPath() string {
|
||||||
|
usr, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return path.Join(usr.HomeDir, ".pmomusic.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnvOverrides(cfg *Config) {
|
||||||
|
for _, env := range os.Environ() {
|
||||||
|
if !strings.HasPrefix(env, envPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split env var into key and value
|
||||||
|
parts := strings.SplitN(env, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPath := strings.Split(strings.TrimPrefix(parts[0], envPrefix), "__")
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
overrideConfig(cfg, keyPath, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertYAMLScalar(s string) interface{} {
|
||||||
|
var out interface{}
|
||||||
|
err := yaml.Unmarshal([]byte(s), &out)
|
||||||
|
if err != nil {
|
||||||
|
// fallback: keep string if parsing failed
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func overrideConfig(cfg *Config, keyPath []string, value string) {
|
||||||
|
iv := convertYAMLScalar(value)
|
||||||
|
cfg.setValue(keyPath, iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lowerKeysMap(m map[string]interface{}) map[string]interface{} {
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
for k, v := range m {
|
||||||
|
lk := strings.ToLower(k)
|
||||||
|
// si c'est une map imbriquée, traiter récursivement
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
out[lk] = lowerKeysMap(vv)
|
||||||
|
default:
|
||||||
|
out[lk] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConfig() *Config {
|
||||||
|
|
||||||
|
if _CONFIG == nil {
|
||||||
|
_CONFIG = LoadConfig("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return _CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conf *Config) GetBaseURL() string {
|
||||||
|
url, _ := conf.GetValue([]string{"host", "base_url"})
|
||||||
|
surl, ok := url.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return surl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conf *Config) GetHTTPPort() int {
|
||||||
|
port, _ := conf.GetValue([]string{"host", "http_port"})
|
||||||
|
|
||||||
|
iport, ok := port.(int)
|
||||||
|
if !ok {
|
||||||
|
return 1900
|
||||||
|
}
|
||||||
|
|
||||||
|
return iport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conf *Config) GetDeviceUDN(devtype DeviceType, name string) string {
|
||||||
|
udn, error := conf.GetValue([]string{"devices", string(devtype), name, "udn"})
|
||||||
|
|
||||||
|
if error != nil {
|
||||||
|
udn = uuid.New().String()
|
||||||
|
conf.SetValue([]string{"devices", string(devtype), name, "udn"}, udn)
|
||||||
|
conf.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return udn.(string)
|
||||||
|
}
|
||||||
35
upnp/debug_index.go
Normal file
35
upnp/debug_index.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) ServeDebugIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>UPnP Debug Interface</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 2em; }
|
||||||
|
h1 { border-bottom: 1px solid #ccc; }
|
||||||
|
pre { background: #f5f5f5; padding: 1em; overflow-x: auto; }
|
||||||
|
a { color: #007bff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Host %s </h1>
|
||||||
|
<h2>address: %s</h2>`,
|
||||||
|
s.Name(),
|
||||||
|
html.EscapeString(html.EscapeString(s.BaseURL())))
|
||||||
|
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
}
|
||||||
36
upnp/dev_set.go
Normal file
36
upnp/dev_set.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceInstanceSet objectstore.ObjectSet[*DeviceInstance]
|
||||||
|
|
||||||
|
func (m *DeviceInstanceSet) Insert(obj *DeviceInstance) error {
|
||||||
|
return (*objectstore.ObjectSet[*DeviceInstance])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceInstanceSet) InsertOrReplace(obj *DeviceInstance) {
|
||||||
|
(*objectstore.ObjectSet[*DeviceInstance])(m).InsertOrReplace(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set *DeviceInstanceSet) Contains(obj *DeviceInstance) bool {
|
||||||
|
return (*objectstore.ObjectSet[*DeviceInstance])(set).Contains(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceInstanceSet) All() iter.Seq[*DeviceInstance] {
|
||||||
|
return (*objectstore.ObjectSet[*DeviceInstance])(m).All()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceInstanceSet) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("DeviceList")
|
||||||
|
|
||||||
|
for sv := range m.All() {
|
||||||
|
elem.AddChild(sv.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
160
upnp/device.go
Normal file
160
upnp/device.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MediaServer DeviceType = "MediaServer"
|
||||||
|
MediaRenderer DeviceType = "MediaRenderer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
name string
|
||||||
|
devtype DeviceType
|
||||||
|
version int
|
||||||
|
|
||||||
|
friendlyName string
|
||||||
|
manufacturer string
|
||||||
|
manufacturerURL string
|
||||||
|
modelDescription string
|
||||||
|
modelName string
|
||||||
|
modelNumber string
|
||||||
|
modelURL string
|
||||||
|
serialNumber string
|
||||||
|
specVersion string
|
||||||
|
|
||||||
|
services ServiceSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDevice creates a new UPnP Device with the given name and type.
|
||||||
|
// It populates a minimal set of device attributes such as
|
||||||
|
// FriendlyName, Manufacturer, ModelName and a default version.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
//
|
||||||
|
// name – the human‑readable name of the device.
|
||||||
|
// devtype – a unique string identifying the device type
|
||||||
|
// (used as the Device Identifier as well).
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
//
|
||||||
|
// *Device – a pointer to the freshly allocated Device instance.
|
||||||
|
// The caller owns the reference and may further
|
||||||
|
// customise the device by setting additional fields
|
||||||
|
// or services.
|
||||||
|
//
|
||||||
|
// Side effects:
|
||||||
|
//
|
||||||
|
// No I/O or external calls are performed. The function
|
||||||
|
// simply constructs a struct in memory; it is safe to
|
||||||
|
// call from multiple goroutines.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a speaker device and register it with a server.
|
||||||
|
// dev := upnp.NewDevice("LivingRoomSpeaker", "AudioDevice")
|
||||||
|
// server.RegisterDevice(dev.Name(), dev)
|
||||||
|
func NewDevice(name string, devtype DeviceType) *Device {
|
||||||
|
switch devtype {
|
||||||
|
case MediaServer, MediaRenderer:
|
||||||
|
dev := &Device{
|
||||||
|
name: name,
|
||||||
|
devtype: devtype,
|
||||||
|
friendlyName: "PMOMusic - " + name,
|
||||||
|
manufacturer: "Petit Maison Orange",
|
||||||
|
modelName: "PMOMusic - " + name,
|
||||||
|
version: 1,
|
||||||
|
services: make(ServiceSet),
|
||||||
|
}
|
||||||
|
return dev
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) Name() string {
|
||||||
|
return d.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) SetName(name string) {
|
||||||
|
d.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) SetFriendlyName(name string) {
|
||||||
|
d.friendlyName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) SetModelName(name string) {
|
||||||
|
d.modelName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) TypeID() string {
|
||||||
|
return "Device"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) DeviceType() DeviceType {
|
||||||
|
return d.devtype
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) SetVersion(version int) error {
|
||||||
|
if version < 1 {
|
||||||
|
return fmt.Errorf("%s", "version must be greater than or equal to 1")
|
||||||
|
}
|
||||||
|
d.version = version
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) Version() int {
|
||||||
|
return d.version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) Manufacturer() string {
|
||||||
|
return d.manufacturer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) SetManufacturer(manufacturer string) {
|
||||||
|
d.manufacturer = manufacturer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) ModelName() string {
|
||||||
|
return d.modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) AddService(srv *Service) error {
|
||||||
|
err := d.services.Insert(srv)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) NewInstance(server *Server, udn string) *DeviceInstance {
|
||||||
|
di := &DeviceInstance{
|
||||||
|
name: d.name,
|
||||||
|
devtype: d.devtype,
|
||||||
|
version: d.version,
|
||||||
|
udn: udn,
|
||||||
|
server: server,
|
||||||
|
friendlyName: d.friendlyName,
|
||||||
|
manufacturer: d.manufacturer,
|
||||||
|
manufacturerURL: d.manufacturerURL,
|
||||||
|
modelDescription: d.modelDescription,
|
||||||
|
modelName: d.modelName,
|
||||||
|
modelNumber: d.modelNumber,
|
||||||
|
modelURL: d.modelURL,
|
||||||
|
serialNumber: d.serialNumber,
|
||||||
|
specVersion: d.specVersion,
|
||||||
|
devices: make(DeviceInstanceSet), // vide initialement
|
||||||
|
services: make(ServiceInstanceSet),
|
||||||
|
}
|
||||||
|
|
||||||
|
for svc := range d.services.All() {
|
||||||
|
i := svc.NewInstance()
|
||||||
|
i.device = di
|
||||||
|
di.services.Insert(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return di
|
||||||
|
}
|
||||||
140
upnp/deviceinstance.go
Normal file
140
upnp/deviceinstance.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceInstance struct {
|
||||||
|
// Identification spécifique à l’instance
|
||||||
|
name string
|
||||||
|
devtype DeviceType
|
||||||
|
version int
|
||||||
|
udn string
|
||||||
|
server *Server
|
||||||
|
|
||||||
|
// Copie figée des infos du Device
|
||||||
|
friendlyName string
|
||||||
|
manufacturer string
|
||||||
|
manufacturerURL string
|
||||||
|
modelDescription string
|
||||||
|
modelName string
|
||||||
|
modelNumber string
|
||||||
|
modelURL string
|
||||||
|
serialNumber string
|
||||||
|
specVersion string
|
||||||
|
|
||||||
|
// Sous-devices si le device en contient
|
||||||
|
devices DeviceInstanceSet
|
||||||
|
services ServiceInstanceSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) Name() string {
|
||||||
|
return di.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) TypeID() string {
|
||||||
|
return "DeviceInstance"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) DeviceType() DeviceType {
|
||||||
|
return di.devtype
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) UDN() string {
|
||||||
|
return di.udn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) ServiceType() string {
|
||||||
|
return fmt.Sprintf("urn:schemas-upnp-org:device:%s:%d", di.devtype, di.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) FriendlyName() string {
|
||||||
|
return di.friendlyName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) Manufacturer() string {
|
||||||
|
return di.manufacturer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) ModelName() string {
|
||||||
|
return di.modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) BaseRoute() string {
|
||||||
|
return fmt.Sprintf("/device/%s/%s", di.DeviceType(), di.UDN())
|
||||||
|
}
|
||||||
|
func (di *DeviceInstance) DescriptionURL() string {
|
||||||
|
return fmt.Sprintf("%s/desc.xml", di.BaseRoute())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) RegisterURLs() error {
|
||||||
|
|
||||||
|
mux, ok := di.server.httpSrv.Handler.(*http.ServeMux)
|
||||||
|
|
||||||
|
if mux == nil || !ok {
|
||||||
|
return fmt.Errorf("❌ Device %s the server handler is not correctly defined", di.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc(
|
||||||
|
di.DescriptionURL(),
|
||||||
|
di.server.ServeXML(di.ToXMLElement),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.Infof(
|
||||||
|
"✅ Device description for %s available at : %s%s",
|
||||||
|
di.Name(),
|
||||||
|
di.server.BaseURL(),
|
||||||
|
di.DescriptionURL(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for svc := range di.services.All() {
|
||||||
|
err := svc.RegisterURLs()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"❌ Service %s:%s URL error: %v",
|
||||||
|
di.Name(),
|
||||||
|
svc.Name(),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (di *DeviceInstance) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("root")
|
||||||
|
elem.CreateAttr("xmlns", "urn:schemas-upnp-org:device-1-0")
|
||||||
|
|
||||||
|
spec := elem.CreateElement("specVersion")
|
||||||
|
spec.CreateElement("major").SetText("1")
|
||||||
|
spec.CreateElement("minor").SetText("0")
|
||||||
|
|
||||||
|
device := elem.CreateElement("device")
|
||||||
|
device.CreateElement("deviceType").SetText(di.ServiceType())
|
||||||
|
device.CreateElement("friendlyName").SetText(di.FriendlyName())
|
||||||
|
device.CreateElement("manufacturer").SetText(di.Manufacturer())
|
||||||
|
device.CreateElement("modelName").SetText(di.ModelName())
|
||||||
|
device.CreateElement("UDN").SetText(di.UDN())
|
||||||
|
|
||||||
|
if len(di.services) > 0 {
|
||||||
|
device.AddChild(di.services.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
|
|
||||||
|
// // NewMediaRendererDescription génère la device description complète
|
||||||
|
// // comme *etree.Element (racine <root>).
|
||||||
|
// func NewMediaRendererDescription(udn string, friendlyName string) *etree.Element {
|
||||||
|
|
||||||
|
// // Inject serviceList
|
||||||
|
// device.AddChild(NewMediaRendererServiceList(udn))
|
||||||
|
|
||||||
|
// return root
|
||||||
|
// }
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package devices
|
|
||||||
|
|
||||||
type Device struct {
|
|
||||||
deviceType string
|
|
||||||
friendlyName string
|
|
||||||
manufacturer string
|
|
||||||
manufacturerURL string
|
|
||||||
modelDescription string
|
|
||||||
modelName string
|
|
||||||
modelNumber string
|
|
||||||
modelURL string
|
|
||||||
serialNumber string
|
|
||||||
specVersion string
|
|
||||||
presentationURL string
|
|
||||||
UDN string
|
|
||||||
}
|
|
||||||
23
upnp/devices/mediarenderer/AVTransport/ac_Play.go
Normal file
23
upnp/devices/mediarenderer/AVTransport/ac_Play.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package avtransport
|
||||||
|
|
||||||
|
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
|
||||||
|
|
||||||
|
var Play = func() *actions.Action {
|
||||||
|
|
||||||
|
ac := actions.NewAction("Play")
|
||||||
|
ac.AddArgument(
|
||||||
|
actions.NewInArgument(
|
||||||
|
"InstanceID",
|
||||||
|
A_ARG_TYPE_InstanceID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ac.AddArgument(
|
||||||
|
actions.NewInArgument(
|
||||||
|
"Speed",
|
||||||
|
TransportPlaySpeed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ac
|
||||||
|
}()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package avtransport
|
||||||
|
|
||||||
|
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
|
||||||
|
|
||||||
|
var SetAVTransportURI = func() *actions.Action {
|
||||||
|
|
||||||
|
ac := actions.NewAction("SetAVTransportURI")
|
||||||
|
ac.AddArgument(
|
||||||
|
actions.NewInArgument(
|
||||||
|
"InstanceID",
|
||||||
|
A_ARG_TYPE_InstanceID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ac.AddArgument(
|
||||||
|
actions.NewInArgument(
|
||||||
|
"CurrentURI",
|
||||||
|
AVTransportURI,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ac.AddArgument(
|
||||||
|
actions.NewInArgument(
|
||||||
|
"CurrentURIMetaData",
|
||||||
|
AVTransportURIMetaData,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ac
|
||||||
|
}()
|
||||||
16
upnp/devices/mediarenderer/AVTransport/ac_Stop.go
Normal file
16
upnp/devices/mediarenderer/AVTransport/ac_Stop.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package avtransport
|
||||||
|
|
||||||
|
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
|
||||||
|
|
||||||
|
var Stop = func() *actions.Action {
|
||||||
|
|
||||||
|
ac := actions.NewAction("Stop")
|
||||||
|
ac.AddArgument(
|
||||||
|
actions.NewInArgument(
|
||||||
|
"InstanceID",
|
||||||
|
A_ARG_TYPE_InstanceID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ac
|
||||||
|
}()
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package mediarenderer
|
|
||||||
|
|
||||||
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services"
|
|
||||||
|
|
||||||
var AVTransport = func() *services.Service {
|
|
||||||
svc := services.NewService("AVTransport")
|
|
||||||
|
|
||||||
svc.AddVariable(AVTransportURI)
|
|
||||||
svc.AddVariable(AVTransportURIMetaData)
|
|
||||||
svc.AddVariable(CurrentTrackDuration)
|
|
||||||
svc.AddVariable(SeekMode)
|
|
||||||
svc.AddVariable(TransportPlaySpeed)
|
|
||||||
svc.AddVariable(TransportState)
|
|
||||||
svc.AddVariable(TransportStatus)
|
|
||||||
|
|
||||||
return svc
|
|
||||||
}()
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package avtransport
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var A_ARG_TYPE_InstanceID = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_UI4.NewStateValue("A_ARG_TYPE_InstanceID")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package avtransport
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var A_ARG_TYPE_PlaySpeed = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("A_ARG_TYPE_PlaySpeed")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
var TransportPlaySpeed = func() *sv.StateVariable {
|
var TransportPlaySpeed = func() *sv.StateVariable {
|
||||||
|
|
||||||
ts := sv.StateType_String.NewStateValue("TransportPlaySpeed")
|
ts := sv.StateType_String.NewStateValue("TransportPlaySpeed")
|
||||||
|
ts.AppendAllowedValue("1")
|
||||||
|
|
||||||
return ts
|
return ts
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ var TransportState = func() *sv.StateVariable {
|
|||||||
ts.SetAllowedValues(
|
ts.SetAllowedValues(
|
||||||
"STOPPED",
|
"STOPPED",
|
||||||
"PLAYING",
|
"PLAYING",
|
||||||
"PAUSED_PLAYBACK",
|
"RECORDING",
|
||||||
"TRANSITIONING",
|
"TRANSITIONING",
|
||||||
|
"PAUSED_PLAYBACK",
|
||||||
|
"PAUSED_RECORDING",
|
||||||
"NO_MEDIA_PRESENT",
|
"NO_MEDIA_PRESENT",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package mediarenderer
|
package avtransport
|
||||||
|
|
||||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
|||||||
23
upnp/devices/mediarenderer/AVTransport/svc_avtransport.go
Normal file
23
upnp/devices/mediarenderer/AVTransport/svc_avtransport.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package avtransport
|
||||||
|
|
||||||
|
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||||
|
|
||||||
|
var AVTransport = func() *upnp.Service {
|
||||||
|
svc := upnp.NewService("AVTransport")
|
||||||
|
|
||||||
|
svc.AddAction(SetAVTransportURI)
|
||||||
|
svc.AddAction(Play)
|
||||||
|
svc.AddAction(Stop)
|
||||||
|
|
||||||
|
svc.AddVariable(A_ARG_TYPE_InstanceID)
|
||||||
|
svc.AddVariable(A_ARG_TYPE_PlaySpeed)
|
||||||
|
svc.AddVariable(AVTransportURI)
|
||||||
|
svc.AddVariable(AVTransportURIMetaData)
|
||||||
|
svc.AddVariable(CurrentTrackDuration)
|
||||||
|
svc.AddVariable(SeekMode)
|
||||||
|
svc.AddVariable(TransportPlaySpeed)
|
||||||
|
svc.AddVariable(TransportState)
|
||||||
|
svc.AddVariable(TransportStatus)
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var AVTransportID = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_I4.NewStateValue("AVTransportID")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var ConnectionID = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_I4.NewStateValue("ConnectionID")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var CurrentConnectionIDs = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("CurrentConnectionIDs")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
11
upnp/devices/mediarenderer/ConnectionManager/sv_Direction.go
Normal file
11
upnp/devices/mediarenderer/ConnectionManager/sv_Direction.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var Direction = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("Direction")
|
||||||
|
ts.AppendAllowedValue("Input", "Ouput")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var PeerConnectionID = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_I4.NewStateValue("PeerConnectionID")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var PeerConnectionManager = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("PeerConnectionManager")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
10
upnp/devices/mediarenderer/ConnectionManager/sv_RcsID.go
Normal file
10
upnp/devices/mediarenderer/ConnectionManager/sv_RcsID.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var RcsID = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_I4.NewStateValue("RcsID")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var SinkProtocolInfo = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("SinkProtocolInfo")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -1 +1,10 @@
|
|||||||
package connectionmanager
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var SourceProtocolInfo = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("SourceProtocolInfo")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
|
|||||||
17
upnp/devices/mediarenderer/ConnectionManager/sv_Status.go
Normal file
17
upnp/devices/mediarenderer/ConnectionManager/sv_Status.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var Status = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("Status")
|
||||||
|
ts.AppendAllowedValue(
|
||||||
|
"OK",
|
||||||
|
"ContentFormatMismatch",
|
||||||
|
"InsufficientBandwidth",
|
||||||
|
"UnreliableChannel",
|
||||||
|
"Unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package connectionmanager
|
||||||
|
|
||||||
|
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||||
|
|
||||||
|
var ConnectionManager = func() *upnp.Service {
|
||||||
|
svc := upnp.NewService("ConnectionManager")
|
||||||
|
|
||||||
|
svc.AddVariable(SourceProtocolInfo)
|
||||||
|
svc.AddVariable(SinkProtocolInfo)
|
||||||
|
svc.AddVariable(SourceProtocolInfo)
|
||||||
|
svc.AddVariable(CurrentConnectionIDs)
|
||||||
|
svc.AddVariable(AVTransportID)
|
||||||
|
svc.AddVariable(RcsID)
|
||||||
|
svc.AddVariable(PeerConnectionManager)
|
||||||
|
svc.AddVariable(PeerConnectionID)
|
||||||
|
svc.AddVariable(Direction)
|
||||||
|
svc.AddVariable(Status)
|
||||||
|
svc.AddVariable(ConnectionID)
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}()
|
||||||
11
upnp/devices/mediarenderer/RenderingControl/sv_Channel.go
Normal file
11
upnp/devices/mediarenderer/RenderingControl/sv_Channel.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package renderingcontrol
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var Channel = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_String.NewStateValue("Channel")
|
||||||
|
ts.AppendAllowedValue("Master", "LF", "RF")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
10
upnp/devices/mediarenderer/RenderingControl/sv_InstanceID.go
Normal file
10
upnp/devices/mediarenderer/RenderingControl/sv_InstanceID.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package renderingcontrol
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var InstanceID = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_I4.NewStateValue("InstanceID")
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
12
upnp/devices/mediarenderer/RenderingControl/sv_Mute.go
Normal file
12
upnp/devices/mediarenderer/RenderingControl/sv_Mute.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package renderingcontrol
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var Mute = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
ts := sv.StateType_Boolean.NewStateValue("Mute")
|
||||||
|
ts.SetSendingEvents()
|
||||||
|
ts.SetDefault(false)
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}()
|
||||||
15
upnp/devices/mediarenderer/RenderingControl/sv_Volume.go
Normal file
15
upnp/devices/mediarenderer/RenderingControl/sv_Volume.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package renderingcontrol
|
||||||
|
|
||||||
|
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
|
||||||
|
var Volume = func() *sv.StateVariable {
|
||||||
|
|
||||||
|
vol := sv.StateType_UI2.NewStateValue("Volume")
|
||||||
|
|
||||||
|
vol.SetRange(0, 100)
|
||||||
|
vol.SetStep(1)
|
||||||
|
|
||||||
|
vol.SetSendingEvents()
|
||||||
|
|
||||||
|
return vol
|
||||||
|
}()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package renderingcontrol
|
||||||
|
|
||||||
|
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||||
|
|
||||||
|
var RenderingControl = func() *upnp.Service {
|
||||||
|
svc := upnp.NewService("RenderingControl.")
|
||||||
|
|
||||||
|
svc.AddVariable(InstanceID)
|
||||||
|
svc.AddVariable(Channel)
|
||||||
|
svc.AddVariable(Mute)
|
||||||
|
svc.AddVariable(Volume)
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}()
|
||||||
18
upnp/devices/mediarenderer/mr_fake_renderer.go
Normal file
18
upnp/devices/mediarenderer/mr_fake_renderer.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package mediarenderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||||
|
avtransport "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer/AVTransport"
|
||||||
|
connectionmanager "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer/ConnectionManager"
|
||||||
|
renderingcontrol "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer/RenderingControl"
|
||||||
|
)
|
||||||
|
|
||||||
|
var FakeRenderer = func() *upnp.Device {
|
||||||
|
|
||||||
|
renderer := upnp.NewDevice("FakeRenderer", "MediaRenderer")
|
||||||
|
|
||||||
|
renderer.AddService(avtransport.AVTransport)
|
||||||
|
renderer.AddService(connectionmanager.ConnectionManager)
|
||||||
|
renderer.AddService(renderingcontrol.RenderingControl)
|
||||||
|
return renderer
|
||||||
|
}()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import "github.com/beevik/etree"
|
import "maps"
|
||||||
|
|
||||||
type Action struct {
|
type Action struct {
|
||||||
name string
|
name string
|
||||||
@@ -10,7 +10,8 @@ type Action struct {
|
|||||||
|
|
||||||
func NewAction(name string) *Action {
|
func NewAction(name string) *Action {
|
||||||
ac := &Action{
|
ac := &Action{
|
||||||
name: name,
|
name: name,
|
||||||
|
arguments: make(ArgumentSet),
|
||||||
}
|
}
|
||||||
|
|
||||||
return ac
|
return ac
|
||||||
@@ -28,12 +29,10 @@ func (a *Action) AddArgument(arg *Argument) {
|
|||||||
a.arguments.Insert(arg)
|
a.arguments.Insert(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Action) ToXMLElement() *etree.Element {
|
func (a *Action) NewInstance() *ActionInstance {
|
||||||
elem := etree.NewElement("action")
|
ac := &ActionInstance{
|
||||||
|
model: a,
|
||||||
name := elem.CreateElement("name")
|
arguments: maps.Clone(a.arguments),
|
||||||
name.SetText(a.Name())
|
}
|
||||||
|
return ac
|
||||||
elem.AddChild(a.arguments.ToXMLElement())
|
|
||||||
return elem
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
upnp/devices/services/actions/actioninstance.go
Normal file
27
upnp/devices/services/actions/actioninstance.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import "github.com/beevik/etree"
|
||||||
|
|
||||||
|
type ActionInstance struct {
|
||||||
|
model *Action
|
||||||
|
|
||||||
|
arguments ArgumentSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ActionInstance) Name() string {
|
||||||
|
return a.model.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ActionInstance) TypeID() string {
|
||||||
|
return "ActionInstance"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ActionInstance) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("action")
|
||||||
|
|
||||||
|
name := elem.CreateElement("name")
|
||||||
|
name.SetText(a.Name())
|
||||||
|
|
||||||
|
elem.AddChild(a.arguments.ToXMLElement())
|
||||||
|
return elem
|
||||||
|
}
|
||||||
32
upnp/devices/services/actions/actioninstanceset.go
Normal file
32
upnp/devices/services/actions/actioninstanceset.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionInstanceSet objectstore.ObjectSet[*ActionInstance]
|
||||||
|
|
||||||
|
func (m *ActionInstanceSet) Insert(obj *ActionInstance) {
|
||||||
|
(*objectstore.ObjectSet[*ActionInstance])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set *ActionInstanceSet) Contains(obj *ActionInstance) bool {
|
||||||
|
return (*objectstore.ObjectSet[*ActionInstance])(set).Contains(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ActionInstanceSet) All() iter.Seq[*ActionInstance] {
|
||||||
|
return (*objectstore.ObjectSet[*ActionInstance])(m).All()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ActionInstanceSet) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("actionList")
|
||||||
|
|
||||||
|
for sv := range m.All() {
|
||||||
|
elem.AddChild(sv.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"iter"
|
"iter"
|
||||||
|
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
"github.com/beevik/etree"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActionSet objectstore.ObjectSet[*Action]
|
type ActionSet objectstore.ObjectSet[*Action]
|
||||||
|
|
||||||
func (m *ActionSet) Insert(obj *Action) {
|
func (m *ActionSet) Insert(obj *Action) error {
|
||||||
(*objectstore.ObjectSet[*Action])(m).Insert(obj)
|
return (*objectstore.ObjectSet[*Action])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ActionSet) InsertOrReplace(obj *Action) {
|
||||||
|
(*objectstore.ObjectSet[*Action])(m).InsertOrReplace(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (set *ActionSet) Contains(obj *Action) bool {
|
func (set *ActionSet) Contains(obj *Action) bool {
|
||||||
@@ -20,13 +23,3 @@ func (set *ActionSet) Contains(obj *Action) bool {
|
|||||||
func (m *ActionSet) All() iter.Seq[*Action] {
|
func (m *ActionSet) All() iter.Seq[*Action] {
|
||||||
return (*objectstore.ObjectSet[*Action])(m).All()
|
return (*objectstore.ObjectSet[*Action])(m).All()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ActionSet) ToXMLElement() *etree.Element {
|
|
||||||
elem := etree.NewElement("ActionList")
|
|
||||||
|
|
||||||
for sv := range m.All() {
|
|
||||||
elem.AddChild(sv.ToXMLElement())
|
|
||||||
}
|
|
||||||
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"iter"
|
|
||||||
|
|
||||||
"github.com/beevik/etree"
|
|
||||||
|
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
|
|
||||||
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
name string
|
|
||||||
identifier string
|
|
||||||
version int
|
|
||||||
|
|
||||||
controlURL string
|
|
||||||
eventSubURL string
|
|
||||||
scpdURL string
|
|
||||||
|
|
||||||
actions actions.ActionSet
|
|
||||||
stateTable sv.StateVariableSet
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(name string) *Service {
|
|
||||||
svc := &Service{
|
|
||||||
name: name,
|
|
||||||
identifier: name,
|
|
||||||
controlURL: "/service/" + name + "/control",
|
|
||||||
eventSubURL: "/service/" + name + "/event",
|
|
||||||
scpdURL: "/service/" + name + "/desc.xml",
|
|
||||||
version: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
return svc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) Name() string {
|
|
||||||
return svc.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) TypeID() string {
|
|
||||||
return "Service"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) ServiceType() string {
|
|
||||||
return fmt.Sprintf("urn:schemas-upnp-org:service:%s:%d", svc.name, svc.version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) ServiceId() string {
|
|
||||||
return fmt.Sprintf("urn:upnp-org:serviceId:%s", svc.identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) SetIdentifier(id string) {
|
|
||||||
svc.identifier = id
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) ControlURL() string {
|
|
||||||
return svc.controlURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) SetControlURL(url string) {
|
|
||||||
svc.controlURL = url
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) EventSubURL() string {
|
|
||||||
return svc.eventSubURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) SetEventSubURL(url string) {
|
|
||||||
svc.eventSubURL = url
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) SCPDURL() string {
|
|
||||||
return svc.scpdURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) SetSCPDURL(url string) {
|
|
||||||
svc.scpdURL = url
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) SetVersion(version int) error {
|
|
||||||
if version < 1 {
|
|
||||||
return fmt.Errorf("%s", "version must be greater than or equal to 1")
|
|
||||||
}
|
|
||||||
svc.version = version
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) Version() int {
|
|
||||||
return svc.version
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) AddVariable(sv *sv.StateVariable) {
|
|
||||||
svc.stateTable.Insert(sv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) ContaintsVariable(sv *sv.StateVariable) bool {
|
|
||||||
return svc.stateTable.Contains(sv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) Variables() iter.Seq[*sv.StateVariable] {
|
|
||||||
return svc.stateTable.All()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) AddAction(ac *actions.Action) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) ToXMLElement() *etree.Element {
|
|
||||||
elem := etree.NewElement("service")
|
|
||||||
|
|
||||||
st := elem.CreateElement("serviceType")
|
|
||||||
st.SetText(svc.ServiceType())
|
|
||||||
|
|
||||||
sid := elem.CreateElement("serviceId")
|
|
||||||
sid.SetText(svc.ServiceId())
|
|
||||||
|
|
||||||
spcd := elem.CreateElement("SCPDURL")
|
|
||||||
spcd.SetText(svc.SCPDURL())
|
|
||||||
|
|
||||||
ctrl := elem.CreateElement("controlURL")
|
|
||||||
ctrl.SetText(svc.ControlURL())
|
|
||||||
|
|
||||||
event := elem.CreateElement("eventSubURL")
|
|
||||||
event.SetText(svc.EventSubURL())
|
|
||||||
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
package statevariables
|
package statevariables
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/beevik/etree"
|
"github.com/beevik/etree"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StateVarInstance struct {
|
type StateVarInstance struct {
|
||||||
model *StateVariable
|
model *StateVariable
|
||||||
|
name string
|
||||||
|
modifiable bool
|
||||||
|
description string
|
||||||
|
step interface{} // Step size for incremental state values (e.g., "10")
|
||||||
|
defaultValue interface{}
|
||||||
|
valueRange *ValueRange
|
||||||
|
eventConditions map[string]StateConditionFunc
|
||||||
|
allowedValues []interface{}
|
||||||
|
sendEvents bool
|
||||||
|
parse StringValueParser
|
||||||
|
marshal ValueSerializer
|
||||||
|
|
||||||
value interface{}
|
value interface{}
|
||||||
previousValue interface{}
|
previousValue interface{}
|
||||||
lastChange time.Time
|
lastChange time.Time
|
||||||
@@ -16,14 +34,133 @@ type StateVarInstance struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) Name() string {
|
||||||
|
return instance.model.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sv *StateVarInstance) TypeID() string {
|
||||||
|
return "StateVarInstance"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) BitSize() int {
|
||||||
|
return instance.model.BitSize()
|
||||||
|
}
|
||||||
|
|
||||||
func (instance *StateVarInstance) Cast(val interface{}) (interface{}, error) {
|
func (instance *StateVarInstance) Cast(val interface{}) (interface{}, error) {
|
||||||
return instance.model.Cast(val)
|
return instance.model.Cast(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) HasDefault() bool {
|
||||||
|
return instance.defaultValue != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) DefaultValue() interface{} {
|
||||||
|
return instance.defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) HasRange() bool {
|
||||||
|
return instance.valueRange != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) Minimum() interface{} {
|
||||||
|
if instance.valueRange == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return instance.valueRange.min
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) Maximum() interface{} {
|
||||||
|
if instance.valueRange == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return instance.valueRange.max
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) IsSendingEvents() bool {
|
||||||
|
return instance.sendEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) HasAllowedValues() bool {
|
||||||
|
return len(instance.allowedValues) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) AllowedValues() []interface{} {
|
||||||
|
return instance.allowedValues
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValueInRange checks if a value falls within the defined range.
|
||||||
|
// Always returns true if no range is set.
|
||||||
|
|
||||||
|
// Parameters:
|
||||||
|
|
||||||
|
// value: Value to check
|
||||||
|
|
||||||
|
// Returns:
|
||||||
|
|
||||||
|
// bool: True if within range or no range defined
|
||||||
|
func (instance *StateVarInstance) IsValueInRange(value interface{}) (bool, error) {
|
||||||
|
return instance.model.valueType.InRange(value, instance.valueRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) IsValueAllowed(value interface{}) (bool, error) {
|
||||||
|
if !instance.HasAllowedValues() {
|
||||||
|
return true, nil // No list = any value valid
|
||||||
|
}
|
||||||
|
cvalue, err := instance.Cast(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowed := range instance.allowedValues {
|
||||||
|
if reflect.DeepEqual(cvalue, allowed) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) IsValidValue(value interface{}) (bool, error) {
|
||||||
|
cvalue, err := instance.Cast(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inrange, err1 := instance.IsValueInRange(cvalue)
|
||||||
|
allowed, err2 := instance.IsValueAllowed(cvalue)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
if err1 != nil {
|
||||||
|
err = err1
|
||||||
|
} else {
|
||||||
|
err = err2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inrange && allowed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) HasDescription() bool {
|
||||||
|
return len(instance.description) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) Description() string {
|
||||||
|
return instance.description
|
||||||
|
}
|
||||||
|
|
||||||
func (instance *StateVarInstance) Model() *StateVariable {
|
func (instance *StateVarInstance) Model() *StateVariable {
|
||||||
return instance.model
|
return instance.model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) IsConstant() bool {
|
||||||
|
return !instance.modifiable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) HasStep() bool {
|
||||||
|
return instance.step != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instance *StateVarInstance) Step() interface{} {
|
||||||
|
return instance.step
|
||||||
|
}
|
||||||
|
|
||||||
func (instance *StateVarInstance) Value() interface{} {
|
func (instance *StateVarInstance) Value() interface{} {
|
||||||
instance.mu.RLock()
|
instance.mu.RLock()
|
||||||
defer instance.mu.RUnlock()
|
defer instance.mu.RUnlock()
|
||||||
@@ -68,8 +205,136 @@ func (sv *StateVarInstance) GenerateEvent() *etree.Element {
|
|||||||
|
|
||||||
prop := propSet.CreateElement("e:property")
|
prop := propSet.CreateElement("e:property")
|
||||||
elem := prop.CreateElement(sv.model.Name())
|
elem := prop.CreateElement(sv.model.Name())
|
||||||
elem.SetText(sv.model.valueToString(sv.Value()))
|
elem.SetText(sv.valueToString(sv.Value()))
|
||||||
|
|
||||||
return propSet
|
return propSet
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToXMLElement generates the complete XML representation of the state variable
|
||||||
|
// Returns an etree.Element that can be serialized to XML
|
||||||
|
func (sv *StateVarInstance) ToXMLElement() *etree.Element {
|
||||||
|
// Create root <stateVariable> element
|
||||||
|
elem := etree.NewElement("stateVariable")
|
||||||
|
|
||||||
|
// Add sendEvents attribute (UPnP eventing capability)
|
||||||
|
if sv.sendEvents {
|
||||||
|
elem.CreateAttr("sendEvents", "yes") // Enable event notifications
|
||||||
|
} else {
|
||||||
|
elem.CreateAttr("sendEvents", "no") // Disable event notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
name := elem.CreateElement("name")
|
||||||
|
name.SetText(sv.Name())
|
||||||
|
|
||||||
|
// Add data type element
|
||||||
|
dataType := elem.CreateElement("dataType")
|
||||||
|
dataType.SetText(sv.model.valueType.String()) // Set UPnP type name (e.g., "ui1", "boolean")
|
||||||
|
|
||||||
|
// Add default value if specified
|
||||||
|
if sv.defaultValue != nil {
|
||||||
|
defaultValue := elem.CreateElement("defaultValue")
|
||||||
|
// Convert value to UPnP-compatible string representation
|
||||||
|
defaultValue.SetText(sv.valueToString(sv.defaultValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add value range constraints if defined
|
||||||
|
if sv.valueRange != nil {
|
||||||
|
rangeElem := elem.CreateElement("allowedValueRange")
|
||||||
|
|
||||||
|
// Minimum boundary value
|
||||||
|
min := rangeElem.CreateElement("minimum")
|
||||||
|
min.SetText(sv.valueToString(sv.valueRange.min))
|
||||||
|
|
||||||
|
// Maximum boundary value
|
||||||
|
max := rangeElem.CreateElement("maximum")
|
||||||
|
max.SetText(sv.valueToString(sv.valueRange.max))
|
||||||
|
|
||||||
|
// Add step value if defined (for incremental controls)
|
||||||
|
if sv.step != nil {
|
||||||
|
step := rangeElem.CreateElement("step")
|
||||||
|
step.SetText(sv.valueToString(sv.step))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add allowed value list if defined
|
||||||
|
if len(sv.allowedValues) > 0 {
|
||||||
|
allowedList := elem.CreateElement("allowedValueList")
|
||||||
|
for _, value := range sv.allowedValues {
|
||||||
|
// Create individual <allowedValue> elements
|
||||||
|
allowed := allowedList.CreateElement("allowedValue")
|
||||||
|
allowed.SetText(sv.valueToString(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add description if available
|
||||||
|
if sv.description != "" {
|
||||||
|
desc := elem.CreateElement("description")
|
||||||
|
desc.SetText(sv.description) // Human-readable description
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueToString converts a value to its UPnP-compatible string representation
|
||||||
|
// Handles type-specific formatting for proper XML serialization
|
||||||
|
func (sv *StateVarInstance) valueToString(val interface{}) string {
|
||||||
|
if val == nil {
|
||||||
|
return "" // Safeguard against nil values
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific formatting for UPnP compliance
|
||||||
|
switch sv.model.valueType {
|
||||||
|
case StateType_Boolean:
|
||||||
|
// Boolean: "1" for true, "0" for false (UPnP standard)
|
||||||
|
if b, ok := val.(bool); ok && b {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
|
||||||
|
case StateType_Date:
|
||||||
|
// Date: YYYY-MM-DD format
|
||||||
|
if t, ok := val.(time.Time); ok {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateType_DateTime, StateType_DateTimeTZ:
|
||||||
|
// DateTime: ISO 8601 format with timezone
|
||||||
|
if t, ok := val.(time.Time); ok {
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateType_Time, StateType_TimeTZ:
|
||||||
|
// Time: HH:MM:SS format
|
||||||
|
if t, ok := val.(time.Time); ok {
|
||||||
|
return t.Format("15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateType_BinBase64:
|
||||||
|
// Binary: Base64 encoding
|
||||||
|
if b, ok := val.([]byte); ok {
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateType_BinHex:
|
||||||
|
// Binary: Hex encoding
|
||||||
|
if b, ok := val.([]byte); ok {
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateType_URI:
|
||||||
|
// URI: Full URL string
|
||||||
|
if u, ok := val.(*url.URL); ok {
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateType_UUID:
|
||||||
|
// UUID: Canonical string representation
|
||||||
|
if u, ok := val.(uuid.UUID); ok {
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default conversion for unsupported types or fallback
|
||||||
|
return fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package statevariables
|
||||||
|
|
||||||
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StateVarInstanceSet objectstore.ObjectSet[*StateVarInstance]
|
||||||
|
|
||||||
|
func (m *StateVarInstanceSet) Insert(obj *StateVarInstance) error {
|
||||||
|
return (*objectstore.ObjectSet[*StateVarInstance])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StateVarInstanceSet) InsertOrReplace(obj *StateVarInstance) {
|
||||||
|
(*objectstore.ObjectSet[*StateVarInstance])(m).InsertOrReplace(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StateVarInstanceSet) Contains(obj *StateVarInstance) bool {
|
||||||
|
return (*objectstore.ObjectSet[*StateVarInstance])(m).Contains(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StateVarInstanceSet) All() iter.Seq[*StateVarInstance] {
|
||||||
|
return (*objectstore.ObjectSet[*StateVarInstance])(m).All()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StateVarInstanceSet) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("serviceStateTable")
|
||||||
|
|
||||||
|
for sv := range m.All() {
|
||||||
|
elem.AddChild(sv.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
package statevariables
|
package statevariables
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"maps"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/beevik/etree"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,7 +120,7 @@ func (state *StateVariable) DefaultValue() interface{} {
|
|||||||
return state.valueType.DefaultValue()
|
return state.valueType.DefaultValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.DefaultValue()
|
return state.defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasRange indicates if a value range constraint is defined.
|
// HasRange indicates if a value range constraint is defined.
|
||||||
@@ -228,20 +224,6 @@ func (state *StateVariable) UpdateMaximalValue(value interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValueInRange checks if a value falls within the defined range.
|
|
||||||
// Always returns true if no range is set.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
//
|
|
||||||
// value: Value to check
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
//
|
|
||||||
// bool: True if within range or no range defined
|
|
||||||
func (state *StateVariable) IsValueInRange(value interface{}) (bool, error) {
|
|
||||||
return state.valueType.InRange(value, state.valueRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSendingEvents indicates if state changes trigger UPnP events.
|
// IsSendingEvents indicates if state changes trigger UPnP events.
|
||||||
func (state *StateVariable) IsSendingEvents() bool {
|
func (state *StateVariable) IsSendingEvents() bool {
|
||||||
return state.sendEvents
|
return state.sendEvents
|
||||||
@@ -298,61 +280,6 @@ func (state *StateVariable) AppendAllowedValue(value ...interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValueAllowed checks if a value exists in the allowed value list.
|
|
||||||
// Always returns true if no allowed values are defined.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
//
|
|
||||||
// value: Value to check
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
//
|
|
||||||
// bool: True if value is permitted or no list defined
|
|
||||||
func (state *StateVariable) IsValueAllowed(value interface{}) (bool, error) {
|
|
||||||
if !state.HasAllowedValues() {
|
|
||||||
return true, nil // No list = any value valid
|
|
||||||
}
|
|
||||||
|
|
||||||
cvalue, err := state.valueType.Cast(value)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, allowed := range state.allowedValues {
|
|
||||||
if reflect.DeepEqual(cvalue, allowed) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidValue performs full validation against all constraints.
|
|
||||||
// Checks (in order):
|
|
||||||
// 1. Value can be cast to the type
|
|
||||||
// 2. Value is within range (if defined)
|
|
||||||
// 3. Value is in allowed list (if defined)
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
//
|
|
||||||
// bool: True if value passes all applicable constraints
|
|
||||||
func (state *StateVariable) IsValidValue(value interface{}) (bool, error) {
|
|
||||||
cvalue, err := state.valueType.Cast(value)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
inrange, err1 := state.IsValueInRange(cvalue)
|
|
||||||
allowed, err2 := state.IsValueAllowed(cvalue)
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
if err1 != nil {
|
|
||||||
err = err1
|
|
||||||
} else {
|
|
||||||
err = err2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inrange && allowed, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *StateVariable) HasDescription() bool {
|
func (state *StateVariable) HasDescription() bool {
|
||||||
return len(state.description) > 0
|
return len(state.description) > 0
|
||||||
}
|
}
|
||||||
@@ -421,139 +348,72 @@ func (state *StateVariable) ClearAllowedValues() {
|
|||||||
state.allowedValues = make([]interface{}, 0)
|
state.allowedValues = make([]interface{}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bool: True if within range or no range defined
|
||||||
|
func (state *StateVariable) IsValueInRange(value interface{}) (bool, error) {
|
||||||
|
return state.valueType.InRange(value, state.valueRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *StateVariable) IsValueAllowed(value interface{}) (bool, error) {
|
||||||
|
if !state.HasAllowedValues() {
|
||||||
|
return true, nil // No list = any value valid
|
||||||
|
}
|
||||||
|
cvalue, err := state.Cast(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowed := range state.allowedValues {
|
||||||
|
if reflect.DeepEqual(cvalue, allowed) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *StateVariable) IsValidValue(value interface{}) (bool, error) {
|
||||||
|
cvalue, err := state.Cast(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inrange, err1 := state.IsValueInRange(cvalue)
|
||||||
|
allowed, err2 := state.IsValueAllowed(cvalue)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
if err1 != nil {
|
||||||
|
err = err1
|
||||||
|
} else {
|
||||||
|
err = err2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inrange && allowed, err
|
||||||
|
}
|
||||||
|
|
||||||
func (state *StateVariable) NewInstance() *StateVarInstance {
|
func (state *StateVariable) NewInstance() *StateVarInstance {
|
||||||
return &StateVarInstance{
|
instance := &StateVarInstance{
|
||||||
model: state,
|
model: state,
|
||||||
|
name: state.name,
|
||||||
|
modifiable: state.modifiable,
|
||||||
|
description: state.description,
|
||||||
|
step: state.step,
|
||||||
|
defaultValue: state.defaultValue,
|
||||||
|
|
||||||
|
eventConditions: maps.Clone(state.eventConditions),
|
||||||
|
allowedValues: slices.Clone(state.allowedValues),
|
||||||
|
sendEvents: state.sendEvents,
|
||||||
|
parse: state.parse,
|
||||||
|
marshal: state.marshal,
|
||||||
|
|
||||||
value: state.DefaultValue(),
|
value: state.DefaultValue(),
|
||||||
lastChange: time.Now(),
|
lastChange: time.Now(),
|
||||||
lastEvent: time.Unix(int64(1718985600), 0).UTC(),
|
lastEvent: time.Unix(int64(1718985600), 0).UTC(),
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if state.HasRange() {
|
||||||
// ToXMLElement generates the complete XML representation of the state variable
|
instance.valueRange = &ValueRange{
|
||||||
// Returns an etree.Element that can be serialized to XML
|
min: state.valueRange.min,
|
||||||
func (sv *StateVariable) ToXMLElement() *etree.Element {
|
max: state.valueRange.max,
|
||||||
// Create root <stateVariable> element
|
}
|
||||||
elem := etree.NewElement("stateVariable")
|
}
|
||||||
|
|
||||||
// Add sendEvents attribute (UPnP eventing capability)
|
return instance
|
||||||
if sv.sendEvents {
|
|
||||||
elem.CreateAttr("sendEvents", "yes") // Enable event notifications
|
|
||||||
} else {
|
|
||||||
elem.CreateAttr("sendEvents", "no") // Disable event notifications
|
|
||||||
}
|
|
||||||
|
|
||||||
name := elem.CreateElement("name")
|
|
||||||
name.SetText(sv.Name())
|
|
||||||
|
|
||||||
// Add data type element
|
|
||||||
dataType := elem.CreateElement("dataType")
|
|
||||||
dataType.SetText(sv.valueType.String()) // Set UPnP type name (e.g., "ui1", "boolean")
|
|
||||||
|
|
||||||
// Add default value if specified
|
|
||||||
if sv.defaultValue != nil {
|
|
||||||
defaultValue := elem.CreateElement("defaultValue")
|
|
||||||
// Convert value to UPnP-compatible string representation
|
|
||||||
defaultValue.SetText(sv.valueToString(sv.defaultValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add value range constraints if defined
|
|
||||||
if sv.valueRange != nil {
|
|
||||||
rangeElem := elem.CreateElement("allowedValueRange")
|
|
||||||
|
|
||||||
// Minimum boundary value
|
|
||||||
min := rangeElem.CreateElement("minimum")
|
|
||||||
min.SetText(sv.valueToString(sv.valueRange.min))
|
|
||||||
|
|
||||||
// Maximum boundary value
|
|
||||||
max := rangeElem.CreateElement("maximum")
|
|
||||||
max.SetText(sv.valueToString(sv.valueRange.max))
|
|
||||||
|
|
||||||
// Add step value if defined (for incremental controls)
|
|
||||||
if sv.step != nil {
|
|
||||||
step := rangeElem.CreateElement("step")
|
|
||||||
step.SetText(sv.valueToString(sv.step))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add allowed value list if defined
|
|
||||||
if len(sv.allowedValues) > 0 {
|
|
||||||
allowedList := elem.CreateElement("allowedValueList")
|
|
||||||
for _, value := range sv.allowedValues {
|
|
||||||
// Create individual <allowedValue> elements
|
|
||||||
allowed := allowedList.CreateElement("allowedValue")
|
|
||||||
allowed.SetText(sv.valueToString(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add description if available
|
|
||||||
if sv.description != "" {
|
|
||||||
desc := elem.CreateElement("description")
|
|
||||||
desc.SetText(sv.description) // Human-readable description
|
|
||||||
}
|
|
||||||
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
|
|
||||||
// valueToString converts a value to its UPnP-compatible string representation
|
|
||||||
// Handles type-specific formatting for proper XML serialization
|
|
||||||
func (sv *StateVariable) valueToString(val interface{}) string {
|
|
||||||
if val == nil {
|
|
||||||
return "" // Safeguard against nil values
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type-specific formatting for UPnP compliance
|
|
||||||
switch sv.valueType {
|
|
||||||
case StateType_Boolean:
|
|
||||||
// Boolean: "1" for true, "0" for false (UPnP standard)
|
|
||||||
if b, ok := val.(bool); ok && b {
|
|
||||||
return "1"
|
|
||||||
}
|
|
||||||
return "0"
|
|
||||||
|
|
||||||
case StateType_Date:
|
|
||||||
// Date: YYYY-MM-DD format
|
|
||||||
if t, ok := val.(time.Time); ok {
|
|
||||||
return t.Format("2006-01-02")
|
|
||||||
}
|
|
||||||
|
|
||||||
case StateType_DateTime, StateType_DateTimeTZ:
|
|
||||||
// DateTime: ISO 8601 format with timezone
|
|
||||||
if t, ok := val.(time.Time); ok {
|
|
||||||
return t.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
case StateType_Time, StateType_TimeTZ:
|
|
||||||
// Time: HH:MM:SS format
|
|
||||||
if t, ok := val.(time.Time); ok {
|
|
||||||
return t.Format("15:04:05")
|
|
||||||
}
|
|
||||||
|
|
||||||
case StateType_BinBase64:
|
|
||||||
// Binary: Base64 encoding
|
|
||||||
if b, ok := val.([]byte); ok {
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
case StateType_BinHex:
|
|
||||||
// Binary: Hex encoding
|
|
||||||
if b, ok := val.([]byte); ok {
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
case StateType_URI:
|
|
||||||
// URI: Full URL string
|
|
||||||
if u, ok := val.(*url.URL); ok {
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
case StateType_UUID:
|
|
||||||
// UUID: Canonical string representation
|
|
||||||
if u, ok := val.(uuid.UUID); ok {
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default conversion for unsupported types or fallback
|
|
||||||
return fmt.Sprintf("%v", val)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package statevariables
|
|
||||||
|
|
||||||
var Volume = func() *StateVariable {
|
|
||||||
|
|
||||||
vol := StateType_UI2.NewStateValue("Volume")
|
|
||||||
|
|
||||||
vol.SetRange(0, 100)
|
|
||||||
vol.SetStep(1)
|
|
||||||
|
|
||||||
vol.SetSendingEvents()
|
|
||||||
|
|
||||||
return vol
|
|
||||||
}()
|
|
||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"iter"
|
"iter"
|
||||||
|
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
"github.com/beevik/etree"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type StateVariableSet objectstore.ObjectSet[*StateVariable]
|
type StateVariableSet objectstore.ObjectSet[*StateVariable]
|
||||||
|
|
||||||
func (m *StateVariableSet) Insert(obj *StateVariable) {
|
func (m *StateVariableSet) Insert(obj *StateVariable) error {
|
||||||
(*objectstore.ObjectSet[*StateVariable])(m).Insert(obj)
|
return (*objectstore.ObjectSet[*StateVariable])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StateVariableSet) InsertOrReplace(obj *StateVariable) {
|
||||||
|
(*objectstore.ObjectSet[*StateVariable])(m).InsertOrReplace(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (set *StateVariableSet) Contains(obj *StateVariable) bool {
|
func (set *StateVariableSet) Contains(obj *StateVariable) bool {
|
||||||
@@ -20,13 +23,3 @@ func (set *StateVariableSet) Contains(obj *StateVariable) bool {
|
|||||||
func (m *StateVariableSet) All() iter.Seq[*StateVariable] {
|
func (m *StateVariableSet) All() iter.Seq[*StateVariable] {
|
||||||
return (*objectstore.ObjectSet[*StateVariable])(m).All()
|
return (*objectstore.ObjectSet[*StateVariable])(m).All()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *StateVariableSet) ToXMLElement() *etree.Element {
|
|
||||||
elem := etree.NewElement("serviceStateTable")
|
|
||||||
|
|
||||||
for sv := range m.All() {
|
|
||||||
elem.AddChild(sv.ToXMLElement())
|
|
||||||
}
|
|
||||||
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package objectstore
|
package objectstore
|
||||||
|
|
||||||
import "iter"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
)
|
||||||
|
|
||||||
type Object interface {
|
type Object interface {
|
||||||
Name() string
|
Name() string
|
||||||
@@ -9,7 +12,15 @@ type Object interface {
|
|||||||
|
|
||||||
type ObjectSet[T Object] map[string]T
|
type ObjectSet[T Object] map[string]T
|
||||||
|
|
||||||
func (m *ObjectSet[T]) Insert(obj T) {
|
func (m *ObjectSet[T]) Insert(obj T) error {
|
||||||
|
if m.Contains(obj) {
|
||||||
|
return fmt.Errorf("object %s already present in set", obj.Name())
|
||||||
|
}
|
||||||
|
(*m)[obj.Name()] = obj
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ObjectSet[T]) InsertOrReplace(obj T) {
|
||||||
(*m)[obj.Name()] = obj
|
(*m)[obj.Name()] = obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
8
upnp/pmomusic.yaml
Normal file
8
upnp/pmomusic.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
host:
|
||||||
|
http_port: "1900"
|
||||||
|
devices:
|
||||||
|
mediarenderer:
|
||||||
|
mpd_renderer:
|
||||||
|
mediaserver:
|
||||||
|
qobuz:
|
||||||
|
udn: "uuid:28963b75-4c5f-4da7-b10e-ffafd"
|
||||||
163
upnp/server.go
Normal file
163
upnp/server.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/netutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
name string
|
||||||
|
HTTPPort int
|
||||||
|
baseURL string
|
||||||
|
|
||||||
|
Logger *log.Logger
|
||||||
|
httpSrv *http.Server
|
||||||
|
|
||||||
|
devices DeviceInstanceSet
|
||||||
|
mu sync.RWMutex
|
||||||
|
startOnce sync.Once
|
||||||
|
stopOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(name string, opts ...ServerOption) *Server {
|
||||||
|
config := GetConfig()
|
||||||
|
|
||||||
|
baseURL := config.GetBaseURL()
|
||||||
|
httpPort := config.GetHTTPPort()
|
||||||
|
|
||||||
|
if baseURL == "" {
|
||||||
|
ip, err := netutils.GuessLocalIP()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("unable to determine local IP: %w", err))
|
||||||
|
}
|
||||||
|
baseURL = fmt.Sprintf("http://%s:%d", ip, httpPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
name: name,
|
||||||
|
HTTPPort: httpPort,
|
||||||
|
baseURL: baseURL,
|
||||||
|
Logger: log.New(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Name() string { return s.name }
|
||||||
|
func (s *Server) TypeID() string { return "Server" }
|
||||||
|
|
||||||
|
type ServerOption func(*Server)
|
||||||
|
|
||||||
|
func WithLogger(l *log.Logger) ServerOption {
|
||||||
|
return func(s *Server) {
|
||||||
|
s.Logger = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
s.startOnce.Do(func() {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
|
||||||
|
mux.HandleFunc("/", s.ServeDebugIndex)
|
||||||
|
|
||||||
|
s.httpSrv = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.HTTPPort),
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
for device := range s.devices.All() {
|
||||||
|
err := device.RegisterURLs()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Panicf("❌ Cannot register URLs: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.Logger.Printf("❌ server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Infof("✅ UPnP server started on %s", s.baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Stop(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
s.stopOnce.Do(func() {
|
||||||
|
if s.httpSrv != nil {
|
||||||
|
s.Logger.Println("✅ Shutting down UPNP server...")
|
||||||
|
err = s.httpSrv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attente d’annulation du contexte
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
// arrêt avec le même ctx ou un nouveau ctx avec timeout
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return s.Stop(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) BaseURL() string { return s.baseURL }
|
||||||
|
|
||||||
|
// ServeXML prend un générateur de XML (*etree.Element)
|
||||||
|
// et renvoie la string XML avec header.
|
||||||
|
func (s *Server) XML(gen func() *etree.Element) (string, error) {
|
||||||
|
root := gen()
|
||||||
|
doc := etree.NewDocument()
|
||||||
|
doc.SetRoot(root)
|
||||||
|
|
||||||
|
doc.Indent(2)
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if _, err := doc.WriteTo(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajoute le header XML
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>` + "\n" + buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeXML(gen func() *etree.Element) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
xmlStr, err := s.XML(gen)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to generate XML", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(xmlStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
95
upnp/service.go
Normal file
95
upnp/service.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
|
||||||
|
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
name string
|
||||||
|
identifier string
|
||||||
|
version int
|
||||||
|
|
||||||
|
actions actions.ActionSet
|
||||||
|
stateTable sv.StateVariableSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(name string) *Service {
|
||||||
|
svc := &Service{
|
||||||
|
name: name,
|
||||||
|
identifier: name,
|
||||||
|
version: 1,
|
||||||
|
stateTable: make(sv.StateVariableSet),
|
||||||
|
actions: make(actions.ActionSet),
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) Name() string {
|
||||||
|
return svc.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) TypeID() string {
|
||||||
|
return "Service"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) Identifier() string {
|
||||||
|
return svc.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) SetIdentifier(id string) {
|
||||||
|
svc.identifier = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) SetVersion(version int) error {
|
||||||
|
if version < 1 {
|
||||||
|
return fmt.Errorf("%s", "version must be greater than or equal to 1")
|
||||||
|
}
|
||||||
|
svc.version = version
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) Version() int {
|
||||||
|
return svc.version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) AddVariable(sv *sv.StateVariable) error {
|
||||||
|
return svc.stateTable.Insert(sv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) ContaintsVariable(sv *sv.StateVariable) bool {
|
||||||
|
return svc.stateTable.Contains(sv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) Variables() iter.Seq[*sv.StateVariable] {
|
||||||
|
return svc.stateTable.All()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) AddAction(ac *actions.Action) error {
|
||||||
|
return svc.actions.Insert(ac)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) NewInstance() *ServiceInstance {
|
||||||
|
instance := &ServiceInstance{
|
||||||
|
name: svc.Name(),
|
||||||
|
identifier: svc.Identifier(),
|
||||||
|
version: svc.Version(),
|
||||||
|
|
||||||
|
statevariables: make(sv.StateVarInstanceSet),
|
||||||
|
actions: make(actions.ActionInstanceSet),
|
||||||
|
}
|
||||||
|
|
||||||
|
for v := range svc.stateTable.All() {
|
||||||
|
instance.statevariables.Insert(v.NewInstance())
|
||||||
|
}
|
||||||
|
|
||||||
|
for a := range svc.actions.All() {
|
||||||
|
instance.actions.Insert(a.NewInstance())
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
121
upnp/serviceinstance.go
Normal file
121
upnp/serviceinstance.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceInstance struct {
|
||||||
|
name string
|
||||||
|
identifier string
|
||||||
|
version int
|
||||||
|
|
||||||
|
device *DeviceInstance
|
||||||
|
statevariables statevariables.StateVarInstanceSet
|
||||||
|
actions actions.ActionInstanceSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *ServiceInstance) Name() string {
|
||||||
|
return si.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) TypeID() string {
|
||||||
|
return "ServiceInstance"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *ServiceInstance) Identifier() string {
|
||||||
|
return si.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) ServiceType() string {
|
||||||
|
return fmt.Sprintf("urn:schemas-upnp-org:service:%s:%d", svc.name, svc.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) ServiceId() string {
|
||||||
|
return fmt.Sprintf("urn:upnp-org:serviceId:%s", svc.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) BaseRoute() string {
|
||||||
|
return fmt.Sprintf("%s/service/%s", svc.device.BaseRoute(), svc.Name())
|
||||||
|
}
|
||||||
|
func (svc *ServiceInstance) ControlURL() string {
|
||||||
|
return fmt.Sprintf("%s/control", svc.BaseRoute())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) EventSubURL() string {
|
||||||
|
return fmt.Sprintf("%s/event", svc.BaseRoute())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) SCPDURL() string {
|
||||||
|
return fmt.Sprintf("%s/desc.xml", svc.BaseRoute())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) RegisterURLs() error {
|
||||||
|
|
||||||
|
mux, ok := svc.device.server.httpSrv.Handler.(*http.ServeMux)
|
||||||
|
|
||||||
|
if mux == nil || !ok {
|
||||||
|
return fmt.Errorf("❌ Device %s the server handler is not correctly defined", svc.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc(
|
||||||
|
svc.SCPDURL(),
|
||||||
|
svc.device.server.ServeXML(svc.SPCDElement),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.Infof(
|
||||||
|
"✅ Service description for %s:%s available at : %s%s",
|
||||||
|
svc.device.Name(),
|
||||||
|
svc.Name(),
|
||||||
|
svc.device.server.BaseURL(),
|
||||||
|
svc.SCPDURL(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) SPCDElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("scpd")
|
||||||
|
|
||||||
|
elem.CreateAttr("xmlns", "urn:schemas-upnp-org:service-1-0")
|
||||||
|
|
||||||
|
spec := elem.CreateElement("specVersion")
|
||||||
|
spec.CreateElement("major").SetText("1")
|
||||||
|
spec.CreateElement("minor").SetText("0")
|
||||||
|
|
||||||
|
if len(svc.actions) > 0 {
|
||||||
|
elem.AddChild(svc.actions.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(svc.statevariables) > 0 {
|
||||||
|
elem.AddChild(svc.statevariables.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *ServiceInstance) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("service")
|
||||||
|
|
||||||
|
st := elem.CreateElement("serviceType")
|
||||||
|
st.SetText(svc.ServiceType())
|
||||||
|
|
||||||
|
sid := elem.CreateElement("serviceId")
|
||||||
|
sid.SetText(svc.ServiceId())
|
||||||
|
|
||||||
|
spcd := elem.CreateElement("SCPDURL")
|
||||||
|
spcd.SetText(svc.SCPDURL())
|
||||||
|
|
||||||
|
ctrl := elem.CreateElement("controlURL")
|
||||||
|
ctrl.SetText(svc.ControlURL())
|
||||||
|
|
||||||
|
event := elem.CreateElement("eventSubURL")
|
||||||
|
event.SetText(svc.EventSubURL())
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
36
upnp/serviceinstanceset.go
Normal file
36
upnp/serviceinstanceset.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceInstanceSet objectstore.ObjectSet[*ServiceInstance]
|
||||||
|
|
||||||
|
func (m *ServiceInstanceSet) Insert(obj *ServiceInstance) error {
|
||||||
|
return (*objectstore.ObjectSet[*ServiceInstance])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ServiceInstanceSet) InsertOrReplace(obj *ServiceInstance) {
|
||||||
|
(*objectstore.ObjectSet[*ServiceInstance])(m).InsertOrReplace(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set *ServiceInstanceSet) Contains(obj *ServiceInstance) bool {
|
||||||
|
return (*objectstore.ObjectSet[*ServiceInstance])(set).Contains(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ServiceInstanceSet) All() iter.Seq[*ServiceInstance] {
|
||||||
|
return (*objectstore.ObjectSet[*ServiceInstance])(m).All()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ServiceInstanceSet) ToXMLElement() *etree.Element {
|
||||||
|
elem := etree.NewElement("ServiceList")
|
||||||
|
|
||||||
|
for sv := range m.All() {
|
||||||
|
elem.AddChild(sv.ToXMLElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
package services
|
package upnp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"iter"
|
"iter"
|
||||||
|
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||||
"github.com/beevik/etree"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceSet objectstore.ObjectSet[*Service]
|
type ServiceSet objectstore.ObjectSet[*Service]
|
||||||
|
|
||||||
func (m *ServiceSet) Insert(obj *Service) {
|
func (m *ServiceSet) Insert(obj *Service) error {
|
||||||
(*objectstore.ObjectSet[*Service])(m).Insert(obj)
|
return (*objectstore.ObjectSet[*Service])(m).Insert(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ServiceSet) InsertOrReplace(obj *Service) {
|
||||||
|
(*objectstore.ObjectSet[*Service])(m).InsertOrReplace(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (set *ServiceSet) Contains(obj *Service) bool {
|
func (set *ServiceSet) Contains(obj *Service) bool {
|
||||||
@@ -20,13 +23,3 @@ func (set *ServiceSet) Contains(obj *Service) bool {
|
|||||||
func (m *ServiceSet) All() iter.Seq[*Service] {
|
func (m *ServiceSet) All() iter.Seq[*Service] {
|
||||||
return (*objectstore.ObjectSet[*Service])(m).All()
|
return (*objectstore.ObjectSet[*Service])(m).All()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ServiceSet) ToXMLElement() *etree.Element {
|
|
||||||
elem := etree.NewElement("ServiceList")
|
|
||||||
|
|
||||||
for sv := range m.All() {
|
|
||||||
elem.AddChild(sv.ToXMLElement())
|
|
||||||
}
|
|
||||||
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
43
upnp/srv_reg_devices.go
Normal file
43
upnp/srv_reg_devices.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package upnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) RegisterDevice(name string, d *Device) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.devices == nil {
|
||||||
|
s.devices = make(DeviceInstanceSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = d.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
config := GetConfig()
|
||||||
|
udn := config.GetDeviceUDN(d.DeviceType(), name)
|
||||||
|
|
||||||
|
instance := d.NewInstance(s, udn)
|
||||||
|
|
||||||
|
log.Infof("✅ Registering device %s", name)
|
||||||
|
|
||||||
|
err := s.devices.Insert(instance)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Panicf("❌ Device %s is already registered", instance.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("✅ New device %s get UDN : %s", instance.Name(), instance.UDN())
|
||||||
|
|
||||||
|
// s.devices[name] = d
|
||||||
|
// d.mu.Lock()
|
||||||
|
// defer d.mu.Unlock()
|
||||||
|
// d.UDN = s.UDN + "-" + name
|
||||||
|
// d.Name = name
|
||||||
|
|
||||||
|
// for _, service := range d.Services.All() {
|
||||||
|
// service.DeviceName = name
|
||||||
|
// }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user