On avance...
This commit is contained in:
@@ -2,45 +2,30 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/renderer"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer"
|
||||
)
|
||||
|
||||
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
|
||||
server := upnp.NewServer("PMO Music Server", "PMO Factory", "Fake Server", "", 1400)
|
||||
server := upnp.NewServer("pmomusic")
|
||||
|
||||
// Crée le renderer UPnP
|
||||
rendererDevice := renderer.NewMusicRenderer("pmomusic Fake Renderer", "pmomusic", "fake model")
|
||||
server.RegisterDevice("MusicRenderer", rendererDevice)
|
||||
server.RegisterDevice("", mediarenderer.FakeRenderer)
|
||||
|
||||
// Lance le serveur HTTP
|
||||
if err := server.Start(); err != nil {
|
||||
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)
|
||||
if err := server.Run(ctx); err != nil {
|
||||
log.Fatalf("server error: %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
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mediarenderer
|
||||
package avtransport
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mediarenderer
|
||||
package avtransport
|
||||
|
||||
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"
|
||||
|
||||
var TransportPlaySpeed = func() *sv.StateVariable {
|
||||
|
||||
ts := sv.StateType_String.NewStateValue("TransportPlaySpeed")
|
||||
ts.AppendAllowedValue("1")
|
||||
|
||||
return ts
|
||||
}()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mediarenderer
|
||||
package avtransport
|
||||
|
||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||
|
||||
@@ -9,8 +9,10 @@ var TransportState = func() *sv.StateVariable {
|
||||
ts.SetAllowedValues(
|
||||
"STOPPED",
|
||||
"PLAYING",
|
||||
"PAUSED_PLAYBACK",
|
||||
"RECORDING",
|
||||
"TRANSITIONING",
|
||||
"PAUSED_PLAYBACK",
|
||||
"PAUSED_RECORDING",
|
||||
"NO_MEDIA_PRESENT",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mediarenderer
|
||||
package avtransport
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
import "github.com/beevik/etree"
|
||||
import "maps"
|
||||
|
||||
type Action struct {
|
||||
name string
|
||||
@@ -11,6 +11,7 @@ type Action struct {
|
||||
func NewAction(name string) *Action {
|
||||
ac := &Action{
|
||||
name: name,
|
||||
arguments: make(ArgumentSet),
|
||||
}
|
||||
|
||||
return ac
|
||||
@@ -28,12 +29,10 @@ func (a *Action) AddArgument(arg *Argument) {
|
||||
a.arguments.Insert(arg)
|
||||
}
|
||||
|
||||
func (a *Action) ToXMLElement() *etree.Element {
|
||||
elem := etree.NewElement("action")
|
||||
|
||||
name := elem.CreateElement("name")
|
||||
name.SetText(a.Name())
|
||||
|
||||
elem.AddChild(a.arguments.ToXMLElement())
|
||||
return elem
|
||||
func (a *Action) NewInstance() *ActionInstance {
|
||||
ac := &ActionInstance{
|
||||
model: a,
|
||||
arguments: maps.Clone(a.arguments),
|
||||
}
|
||||
return ac
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||
"github.com/beevik/etree"
|
||||
)
|
||||
|
||||
type ActionSet objectstore.ObjectSet[*Action]
|
||||
|
||||
func (m *ActionSet) Insert(obj *Action) {
|
||||
(*objectstore.ObjectSet[*Action])(m).Insert(obj)
|
||||
func (m *ActionSet) Insert(obj *Action) error {
|
||||
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 {
|
||||
@@ -20,13 +23,3 @@ func (set *ActionSet) Contains(obj *Action) bool {
|
||||
func (m *ActionSet) All() iter.Seq[*Action] {
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type StateVarInstance struct {
|
||||
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{}
|
||||
previousValue interface{}
|
||||
lastChange time.Time
|
||||
@@ -16,14 +34,133 @@ type StateVarInstance struct {
|
||||
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) {
|
||||
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 {
|
||||
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{} {
|
||||
instance.mu.RLock()
|
||||
defer instance.mu.RUnlock()
|
||||
@@ -68,8 +205,136 @@ func (sv *StateVarInstance) GenerateEvent() *etree.Element {
|
||||
|
||||
prop := propSet.CreateElement("e:property")
|
||||
elem := prop.CreateElement(sv.model.Name())
|
||||
elem.SetText(sv.model.valueToString(sv.Value()))
|
||||
elem.SetText(sv.valueToString(sv.Value()))
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -124,7 +120,7 @@ func (state *StateVariable) DefaultValue() interface{} {
|
||||
return state.valueType.DefaultValue()
|
||||
}
|
||||
|
||||
return state.DefaultValue()
|
||||
return state.defaultValue
|
||||
}
|
||||
|
||||
// HasRange indicates if a value range constraint is defined.
|
||||
@@ -228,20 +224,6 @@ func (state *StateVariable) UpdateMaximalValue(value interface{}) error {
|
||||
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.
|
||||
func (state *StateVariable) IsSendingEvents() bool {
|
||||
return state.sendEvents
|
||||
@@ -298,61 +280,6 @@ func (state *StateVariable) AppendAllowedValue(value ...interface{}) error {
|
||||
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 {
|
||||
return len(state.description) > 0
|
||||
}
|
||||
@@ -421,139 +348,72 @@ func (state *StateVariable) ClearAllowedValues() {
|
||||
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 {
|
||||
return &StateVarInstance{
|
||||
instance := &StateVarInstance{
|
||||
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(),
|
||||
lastChange: time.Now(),
|
||||
lastEvent: time.Unix(int64(1718985600), 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// ToXMLElement generates the complete XML representation of the state variable
|
||||
// Returns an etree.Element that can be serialized to XML
|
||||
func (sv *StateVariable) 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.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))
|
||||
if state.HasRange() {
|
||||
instance.valueRange = &ValueRange{
|
||||
min: state.valueRange.min,
|
||||
max: state.valueRange.max,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return instance
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||
"github.com/beevik/etree"
|
||||
)
|
||||
|
||||
type StateVariableSet objectstore.ObjectSet[*StateVariable]
|
||||
|
||||
func (m *StateVariableSet) Insert(obj *StateVariable) {
|
||||
(*objectstore.ObjectSet[*StateVariable])(m).Insert(obj)
|
||||
func (m *StateVariableSet) Insert(obj *StateVariable) error {
|
||||
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 {
|
||||
@@ -20,13 +23,3 @@ func (set *StateVariableSet) Contains(obj *StateVariable) bool {
|
||||
func (m *StateVariableSet) All() iter.Seq[*StateVariable] {
|
||||
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
|
||||
|
||||
import "iter"
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
)
|
||||
|
||||
type Object interface {
|
||||
Name() string
|
||||
@@ -9,7 +12,15 @@ type Object interface {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
"iter"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/objectstore"
|
||||
"github.com/beevik/etree"
|
||||
)
|
||||
|
||||
type ServiceSet objectstore.ObjectSet[*Service]
|
||||
|
||||
func (m *ServiceSet) Insert(obj *Service) {
|
||||
(*objectstore.ObjectSet[*Service])(m).Insert(obj)
|
||||
func (m *ServiceSet) Insert(obj *Service) error {
|
||||
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 {
|
||||
@@ -20,13 +23,3 @@ func (set *ServiceSet) Contains(obj *Service) bool {
|
||||
func (m *ServiceSet) All() iter.Seq[*Service] {
|
||||
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