On avance...

This commit is contained in:
2025-09-06 17:45:28 +02:00
parent 83a0b62dd3
commit d1132e00cd
81 changed files with 6656 additions and 475 deletions

View File

@@ -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 lIP 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
View 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 laudio | `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 linterface 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
View 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" --&gt; "1..*" Service
Device "0..*" --&gt; "0..*" Device
Service "0..*" --&gt; "0..*" Action
Service "1..*" --&gt; "1..*" StateVariable
%% Associations Runtime Layer
Server "1" --&gt; "1..*" DeviceInstance
DeviceInstance "1" --&gt; "1..*" ServiceInstance
DeviceInstance "0..*" --&gt; "0..*" DeviceInstance
ServiceInstance "1" --&gt; "1..*" StateVariableInstance
ServiceInstance "0..*" --&gt; "0..*" ActionHandler
%% Instantiation links
DeviceInstance ..&gt; Device : instantiates
ServiceInstance ..&gt; Service : instantiates
StateVariableInstance ..&gt; StateVariable : instantiates
ActionHandler ..&gt; 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
View 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;
```

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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 */

View 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);
}

View 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);
}
}
});
}

View 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}

File diff suppressed because one or more lines are too long

131
doc/urls.md Normal file
View File

@@ -0,0 +1,131 @@
Oui. Pour un serveur UPnP/HTTP, lURL de chaque device et service doit être **stable, unique et hiérarchique**, tout en restant simple à mapper depuis lIP 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 dun 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).
---

View 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>

View 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 dactions 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>

View 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>

View 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
View 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
View File

@@ -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
View 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
View File

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

367
upnp/config.go Normal file
View 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
View 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
View 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
View 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 humanreadable 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
View 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 à linstance
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
// }

View File

@@ -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
}

View 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
}()

View File

@@ -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
}()

View 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
}()

View File

@@ -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
}()

View File

@@ -1,4 +1,4 @@
package mediarenderer
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"

View File

@@ -1,4 +1,4 @@
package mediarenderer
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"

View File

@@ -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
}()

View File

@@ -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
}()

View File

@@ -1,4 +1,4 @@
package mediarenderer
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"

View File

@@ -1,4 +1,4 @@
package mediarenderer
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"

View File

@@ -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
}()

View File

@@ -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",
)

View File

@@ -1,4 +1,4 @@
package mediarenderer
package avtransport
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"

View 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
}()

View File

@@ -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
}()

View File

@@ -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
}()

View File

@@ -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
}()

View 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
}()

View File

@@ -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
}()

View File

@@ -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
}()

View 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
}()

View File

@@ -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
}()

View File

@@ -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
}()

View 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
}()

View File

@@ -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
}()

View 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
}()

View 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
}()

View 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
}()

View 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
}()

View File

@@ -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
}()

View 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
}()

View File

@@ -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
}

View 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
}

View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}()

View File

@@ -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
}

View File

@@ -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
View 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
View 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 dannulation 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
View 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
View 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
}

View 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
}

View File

@@ -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
View 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
// }
}