Ajoute une API d'exposition de l'état interne du serveur UPNP et un composant à l'application web qui permet de l'explorer

This commit is contained in:
2025-10-10 12:33:24 +02:00
parent dca6fcb565
commit 2c8a1325b4
10 changed files with 2166 additions and 25 deletions

View File

@@ -3,7 +3,8 @@
<nav>
<router-link to="/">Accueil</router-link> |
<router-link to="/logs">Logs</router-link> |
<router-link to="/covers-cache">Cover Cache</router-link>
<router-link to="/covers-cache">Cover Cache</router-link> |
<router-link to="/upnp">UPnP Explorer</router-link>
</nav>
<router-view />
</div>

View File

@@ -0,0 +1,421 @@
<template>
<div class="upnp-explorer">
<div class="header">
<h2>🎵 UPnP Device Explorer</h2>
<div class="controls">
<button @click="refreshDevices" :disabled="isLoading" class="refresh-btn">
{{ isLoading ? '⏳ Loading...' : '🔄 Refresh' }}
</button>
<span class="device-count">{{ devices.length }} device(s)</span>
</div>
</div>
<!-- État de chargement -->
<div v-if="isLoading && devices.length === 0" class="loading-state">
Loading UPnP devices...
</div>
<!-- État vide -->
<div v-else-if="!isLoading && devices.length === 0" class="empty-state">
<div class="empty-icon">📡</div>
<p>No UPnP devices found</p>
<p class="hint">Devices will appear here once registered</p>
</div>
<!-- Liste des devices avec leurs services intégrés -->
<div v-else class="devices-list">
<div
v-for="device in devicesWithDetails"
:key="device.udn"
class="device-section"
>
<!-- En-tête du device -->
<div class="device-header" @click="toggleDevice(device.udn)">
<div class="device-title">
<span class="device-icon">{{ getDeviceIcon(device.device_type) }}</span>
<div class="device-names">
<span class="device-name">{{ device.friendly_name }}</span>
<span class="device-type">{{ device.name }}</span>
</div>
</div>
<div class="device-meta">
<span v-if="device.services" class="service-count">
{{ device.services.length }} service(s)
</span>
<span class="expand-icon">{{ expandedDevices.has(device.udn) ? '▼' : '▶' }}</span>
</div>
</div>
<!-- Détails du device (expandable) -->
<transition name="expand">
<div v-if="expandedDevices.has(device.udn)" class="device-details">
<!-- Chargement des détails -->
<div v-if="!device.services" class="loading-services">
Loading services...
</div>
<!-- Services -->
<div v-else class="services-list">
<ServicePanel
v-for="service in device.services"
:key="service.name"
:service="service"
:device-udn="device.udn"
/>
</div>
</div>
</transition>
</div>
</div>
<!-- Toast de notification d'erreur -->
<transition name="fade">
<div v-if="error" class="error-toast" @click="error = null">
❌ {{ error }}
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import ServicePanel from './upnp/ServicePanel.vue'
const devices = ref([])
const deviceDetails = ref(new Map()) // UDN -> détails complets
const isLoading = ref(false)
const error = ref(null)
const expandedDevices = ref(new Set())
const refreshInterval = ref(null)
// Devices avec leurs détails fusionnés
const devicesWithDetails = computed(() => {
return devices.value.map(device => {
const details = deviceDetails.value.get(device.udn)
return details ? { ...device, ...details } : device
})
})
function getDeviceIcon(deviceType) {
if (deviceType?.includes('MediaRenderer')) return '🎵'
if (deviceType?.includes('MediaServer')) return '💿'
return '📱'
}
async function loadDevices() {
try {
const response = await fetch('/api/upnp/devices')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
devices.value = data.devices || []
} catch (err) {
console.error('Failed to load devices:', err)
error.value = `Failed to load devices: ${err.message}`
setTimeout(() => error.value = null, 5000)
}
}
async function loadDeviceDetails(udn) {
try {
const response = await fetch(`/api/upnp/devices/${encodeURIComponent(udn)}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const details = await response.json()
deviceDetails.value.set(udn, details)
} catch (err) {
console.error('Failed to load device details:', err)
error.value = `Failed to load device details: ${err.message}`
setTimeout(() => error.value = null, 5000)
}
}
function toggleDevice(udn) {
if (expandedDevices.value.has(udn)) {
expandedDevices.value.delete(udn)
} else {
expandedDevices.value.add(udn)
// Charger les détails si pas encore fait
if (!deviceDetails.value.has(udn)) {
loadDeviceDetails(udn)
}
}
}
async function refreshDevices() {
isLoading.value = true
await loadDevices()
isLoading.value = false
}
// Auto-refresh toutes les 30 secondes
onMounted(() => {
refreshDevices()
refreshInterval.value = setInterval(loadDevices, 30000)
})
onUnmounted(() => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
}
})
</script>
<style scoped>
.upnp-explorer {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid rgba(52, 152, 219, 0.3);
}
.header h2 {
margin: 0;
color: #ecf0f1;
font-size: 1.8rem;
}
.controls {
display: flex;
align-items: center;
gap: 1rem;
}
.refresh-btn {
padding: 0.6rem 1.2rem;
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.refresh-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #5dade2, #3498db);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.device-count {
padding: 0.5rem 1rem;
background: rgba(52, 152, 219, 0.2);
border: 1px solid rgba(52, 152, 219, 0.4);
border-radius: 20px;
color: #3498db;
font-size: 0.9rem;
font-weight: 600;
}
/* États */
.loading-state,
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #95a5a6;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.hint {
font-size: 0.9rem;
color: #7f8c8d;
}
/* Devices list */
.devices-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.device-section {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
}
.device-section:hover {
border-color: rgba(52, 152, 219, 0.6);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.2);
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.2rem 1.5rem;
cursor: pointer;
transition: background 0.2s;
}
.device-header:hover {
background: rgba(52, 152, 219, 0.05);
}
.device-title {
display: flex;
align-items: center;
gap: 1rem;
}
.device-icon {
font-size: 2rem;
}
.device-names {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.device-name {
font-size: 1.2rem;
font-weight: 600;
color: #ecf0f1;
}
.device-type {
font-size: 0.85rem;
color: #95a5a6;
}
.device-meta {
display: flex;
align-items: center;
gap: 1rem;
}
.service-count {
padding: 0.3rem 0.8rem;
background: rgba(46, 204, 113, 0.2);
border: 1px solid rgba(46, 204, 113, 0.3);
border-radius: 12px;
color: #2ecc71;
font-size: 0.85rem;
font-weight: 600;
}
.expand-icon {
color: #3498db;
font-size: 1rem;
transition: transform 0.3s;
}
/* Device details */
.device-details {
padding: 0 1.5rem 1.5rem 1.5rem;
border-top: 1px solid rgba(52, 152, 219, 0.2);
}
.loading-services {
padding: 2rem;
text-align: center;
color: #95a5a6;
}
.services-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
/* Transitions */
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
max-height: 5000px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Error toast */
.error-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: pointer;
z-index: 1000;
max-width: 400px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.upnp-explorer {
padding: 1rem;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.device-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.device-meta {
width: 100%;
justify-content: space-between;
}
}
</style>

View File

@@ -0,0 +1,491 @@
<template>
<div class="actions-list">
<div v-if="!service.actions || service.actions.length === 0" class="empty-state">
<span class="empty-icon"></span>
<p>No actions available for this service</p>
</div>
<div v-else class="actions-content">
<div class="actions-header">
<h4>Actions ({{ service.actions.length }})</h4>
</div>
<div class="actions-grid">
<div
v-for="action in service.actions"
:key="action.name"
class="action-card"
:class="{ expanded: expandedAction === action.name }"
@click="toggleAction(action.name)"
>
<div class="action-header">
<div class="action-title">
<span class="action-icon"></span>
<span class="action-name">{{ action.name }}</span>
</div>
<div class="action-badges">
<span v-if="action.in_arguments.length > 0" class="badge in-badge" title="Input arguments">
{{ action.in_arguments.length }}
</span>
<span v-if="action.out_arguments.length > 0" class="badge out-badge" title="Output arguments">
{{ action.out_arguments.length }}
</span>
<span class="expand-indicator">
{{ expandedAction === action.name ? '▼' : '▶' }}
</span>
</div>
</div>
<transition name="expand-args">
<div v-if="expandedAction === action.name" class="action-details">
<!-- Input arguments -->
<div v-if="action.in_arguments.length > 0" class="arguments-section">
<h5 class="section-title">
<span class="section-icon"></span>
Input Arguments
</h5>
<div class="arguments-list">
<div
v-for="arg in action.in_arguments"
:key="arg.name"
class="argument-item"
>
<div class="argument-header">
<span class="argument-name">{{ arg.name }}</span>
<span class="var-link" @click.stop="scrollToVariable(arg.related_state_variable)">
{{ arg.related_state_variable }}
</span>
</div>
<div v-if="getVariableInfo(arg.related_state_variable)" class="variable-preview">
<div class="preview-row">
<span class="preview-label">Type:</span>
<code class="preview-value type">{{ getVariableInfo(arg.related_state_variable).data_type }}</code>
</div>
<div class="preview-row">
<span class="preview-label">Value:</span>
<code class="preview-value" :class="{ empty: !getVariableInfo(arg.related_state_variable).value }">
{{ getVariableInfo(arg.related_state_variable).value || '(empty)' }}
</code>
</div>
</div>
</div>
</div>
</div>
<!-- Output arguments -->
<div v-if="action.out_arguments.length > 0" class="arguments-section">
<h5 class="section-title">
<span class="section-icon"></span>
Output Arguments
</h5>
<div class="arguments-list">
<div
v-for="arg in action.out_arguments"
:key="arg.name"
class="argument-item out"
>
<div class="argument-header">
<span class="argument-name">{{ arg.name }}</span>
<span class="var-link" @click.stop="scrollToVariable(arg.related_state_variable)">
{{ arg.related_state_variable }}
</span>
</div>
<div v-if="getVariableInfo(arg.related_state_variable)" class="variable-preview">
<div class="preview-row">
<span class="preview-label">Type:</span>
<code class="preview-value type">{{ getVariableInfo(arg.related_state_variable).data_type }}</code>
</div>
<div class="preview-row">
<span class="preview-label">Value:</span>
<code class="preview-value" :class="{ empty: !getVariableInfo(arg.related_state_variable).value }">
{{ getVariableInfo(arg.related_state_variable).value || '(empty)' }}
</code>
</div>
</div>
</div>
</div>
</div>
<!-- No arguments -->
<div v-if="action.in_arguments.length === 0 && action.out_arguments.length === 0" class="no-arguments">
<span class="no-args-icon"></span>
<p>This action has no arguments</p>
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
service: {
type: Object,
required: true
},
deviceUdn: {
type: String,
required: true
}
})
const expandedAction = ref(null)
const variables = ref([])
function toggleAction(actionName) {
expandedAction.value = expandedAction.value === actionName ? null : actionName
}
function getVariableInfo(varName) {
return variables.value.find(v => v.name === varName)
}
function scrollToVariable(varName) {
// TODO: Implement scroll to variable in Variables tab
console.log('Scroll to variable:', varName)
}
async function loadVariables() {
if (!props.deviceUdn || !props.service.name) return
try {
const url = `/api/upnp/devices/${encodeURIComponent(props.deviceUdn)}/services/${encodeURIComponent(props.service.name)}/variables`
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
variables.value = data.variables || []
} catch (err) {
console.error('Error loading variables for actions:', err)
}
}
onMounted(() => {
loadVariables()
})
</script>
<style scoped>
.actions-list {
min-height: 200px;
display: flex;
flex-direction: column;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #95a5a6;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
margin: 0;
}
/* Actions content */
.actions-content {
flex: 1;
}
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1rem;
}
.actions-header h4 {
margin: 0;
color: #ecf0f1;
font-size: 1rem;
font-weight: 600;
}
/* Actions grid */
.actions-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.action-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
}
.action-card:hover {
border-color: rgba(52, 152, 219, 0.6);
background: rgba(0, 0, 0, 0.4);
}
.action-card.expanded {
border-color: #3498db;
background: rgba(52, 152, 219, 0.05);
}
.action-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
transition: background 0.2s;
}
.action-card:hover .action-header {
background: rgba(52, 152, 219, 0.05);
}
.action-title {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.action-icon {
font-size: 1.2rem;
}
.action-name {
font-weight: 600;
color: #ecf0f1;
font-size: 0.95rem;
}
.action-badges {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.in-badge {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
border: 1px solid rgba(52, 152, 219, 0.3);
}
.out-badge {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
border: 1px solid rgba(46, 204, 113, 0.3);
}
.expand-indicator {
color: #3498db;
font-size: 0.9rem;
transition: transform 0.3s;
margin-left: 0.5rem;
}
.action-card.expanded .expand-indicator {
transform: rotate(0deg);
}
/* Action details */
.action-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid rgba(52, 152, 219, 0.2);
}
.arguments-section {
margin-top: 1rem;
}
.arguments-section:first-child {
margin-top: 0.5rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
font-weight: 600;
color: #95a5a6;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-icon {
font-size: 1rem;
}
.arguments-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.argument-item {
background: rgba(52, 152, 219, 0.1);
border: 1px solid rgba(52, 152, 219, 0.2);
border-left: 3px solid #3498db;
border-radius: 4px;
padding: 0.75rem;
}
.argument-item.out {
background: rgba(46, 204, 113, 0.1);
border: 1px solid rgba(46, 204, 113, 0.2);
border-left: 3px solid #2ecc71;
}
.argument-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.argument-name {
font-weight: 600;
color: #ecf0f1;
font-size: 0.9rem;
}
.var-link {
font-size: 0.75rem;
color: #9b59b6;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Courier New', monospace;
}
.var-link:hover {
background: rgba(155, 89, 182, 0.3);
border-color: #9b59b6;
transform: translateY(-1px);
}
/* Variable preview */
.variable-preview {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.preview-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-label {
font-size: 0.7rem;
color: #7f8c8d;
text-transform: uppercase;
font-weight: 600;
min-width: 50px;
}
.preview-value {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: #ecf0f1;
background: rgba(0, 0, 0, 0.3);
padding: 0.15rem 0.4rem;
border-radius: 3px;
}
.preview-value.type {
color: #9b59b6;
background: rgba(155, 89, 182, 0.15);
}
.preview-value.empty {
color: #7f8c8d;
font-style: italic;
}
/* No arguments state */
.no-arguments {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #95a5a6;
}
.no-args-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.no-arguments p {
margin: 0;
font-size: 0.9rem;
}
/* Expand animation */
.expand-args-enter-active,
.expand-args-leave-active {
transition: all 0.3s ease;
max-height: 1000px;
overflow: hidden;
}
.expand-args-enter-from,
.expand-args-leave-to {
max-height: 0;
opacity: 0;
}
/* Responsive */
@media (max-width: 768px) {
.action-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.action-badges {
width: 100%;
justify-content: flex-end;
}
.argument-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</style>

View File

@@ -0,0 +1,307 @@
<template>
<div
class="device-card"
:class="{ expanded: isExpanded }"
@click="handleClick"
>
<div class="card-header">
<div class="device-icon">
{{ getDeviceIcon(device.device_type) }}
</div>
<div class="device-info">
<h3 class="device-name">{{ device.friendly_name }}</h3>
<p class="device-type">{{ formatDeviceType(device.device_type) }}</p>
</div>
<div class="expand-icon">
{{ isExpanded ? '▼' : '▶' }}
</div>
</div>
<div class="card-body">
<div class="device-details">
<div class="detail-item">
<span class="detail-icon">🏷</span>
<span class="detail-label">Name:</span>
<span class="detail-value">{{ device.name }}</span>
</div>
<div class="detail-item">
<span class="detail-icon">🏭</span>
<span class="detail-label">Manufacturer:</span>
<span class="detail-value">{{ device.manufacturer }}</span>
</div>
<div class="detail-item">
<span class="detail-icon">📦</span>
<span class="detail-label">Model:</span>
<span class="detail-value">{{ device.model_name }}</span>
</div>
<div class="detail-item">
<span class="detail-icon">🔗</span>
<span class="detail-label">Base URL:</span>
<a :href="device.base_url" target="_blank" class="detail-value link">
{{ device.base_url }}
</a>
</div>
<div class="detail-item udn">
<span class="detail-icon">🆔</span>
<span class="detail-label">UDN:</span>
<code class="detail-value monospace">{{ device.udn }}</code>
</div>
</div>
<div class="card-actions">
<button
@click.stop="$emit('load-details')"
class="details-btn"
>
📋 View Services
</button>
<a
:href="device.description_url"
target="_blank"
class="xml-btn"
@click.stop
>
📄 Device XML
</a>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
device: {
type: Object,
required: true
},
isExpanded: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle', 'load-details'])
function handleClick() {
emit('toggle')
}
function getDeviceIcon(deviceType) {
if (deviceType.includes('MediaRenderer')) return '🎵'
if (deviceType.includes('MediaServer')) return '💿'
if (deviceType.includes('Display')) return '🖥️'
return '📱'
}
function formatDeviceType(deviceType) {
// Extraire le type simple depuis l'URN
const match = deviceType.match(/device:([^:]+)/)
return match ? match[1] : deviceType
}
</script>
<style scoped>
.device-card {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
border-radius: 12px;
border: 2px solid #3498db;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.device-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(52, 152, 219, 0.4);
border-color: #5dade2;
}
.device-card.expanded {
border-color: #2ecc71;
}
.card-header {
display: flex;
align-items: center;
padding: 1.25rem;
gap: 1rem;
background: rgba(0, 0, 0, 0.2);
}
.device-icon {
font-size: 2.5rem;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.device-info {
flex: 1;
min-width: 0;
}
.device-name {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
font-weight: 600;
color: #ecf0f1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-type {
margin: 0;
font-size: 0.85rem;
color: #3498db;
font-weight: 500;
}
.expand-icon {
font-size: 1.2rem;
color: #3498db;
transition: transform 0.3s;
flex-shrink: 0;
}
.device-card.expanded .expand-icon {
transform: rotate(0deg);
}
.card-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.device-card.expanded .card-body {
max-height: 500px;
}
.device-details {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
transition: background 0.2s;
}
.detail-item:hover {
background: rgba(52, 152, 219, 0.1);
}
.detail-item.udn {
flex-wrap: wrap;
}
.detail-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.detail-label {
font-weight: 600;
color: #95a5a6;
min-width: 100px;
flex-shrink: 0;
}
.detail-value {
color: #ecf0f1;
flex: 1;
word-break: break-word;
}
.monospace {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
background: rgba(0, 0, 0, 0.3);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.link {
color: #3498db;
text-decoration: none;
transition: color 0.2s;
}
.link:hover {
color: #5dade2;
text-decoration: underline;
}
.card-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(52, 152, 219, 0.3);
}
.details-btn,
.xml-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.details-btn {
background: #3498db;
color: white;
}
.details-btn:hover {
background: #2980b9;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.xml-btn {
background: #2ecc71;
color: white;
}
.xml-btn:hover {
background: #27ae60;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
}
/* Animation d'entrée */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.device-card {
animation: slideIn 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="service-panel" :class="{ expanded: isExpanded }">
<div class="service-header" @click="toggleExpand">
<div class="service-icon">🔧</div>
<div class="service-info">
<h4 class="service-name">{{ service.name }}</h4>
<p class="service-type">{{ formatServiceType(service.service_type) }}</p>
</div>
<div class="service-badge">
{{ isExpanded ? '▼' : '▶' }}
</div>
</div>
<transition name="expand">
<div v-if="isExpanded" class="service-content">
<!-- URLs du service -->
<div class="service-urls">
<div class="url-item">
<span class="url-label">Control:</span>
<a :href="service.control_url" target="_blank" class="url-value">
{{ service.control_url }}
</a>
</div>
<div class="url-item">
<span class="url-label">Events:</span>
<a :href="service.event_url" target="_blank" class="url-value">
{{ service.event_url }}
</a>
</div>
<div class="url-item">
<span class="url-label">SCPD:</span>
<a :href="service.scpd_url" target="_blank" class="url-value">
{{ service.scpd_url }}
</a>
</div>
</div>
<!-- Onglets pour Variables / Actions -->
<div class="tabs">
<button
:class="['tab', { active: activeTab === 'variables' }]"
@click="activeTab = 'variables'"
>
📊 Variables
<span class="badge">{{ variablesCount }}</span>
</button>
<button
:class="['tab', { active: activeTab === 'actions' }]"
@click="activeTab = 'actions'"
>
Actions
<span class="badge">{{ actionsCount }}</span>
</button>
</div>
<!-- Contenu des onglets -->
<div class="tab-content">
<VariablesList
v-if="activeTab === 'variables'"
:device-udn="deviceUdn"
:service-name="service.name"
/>
<ActionsList
v-else
:service="service"
:device-udn="deviceUdn"
/>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import VariablesList from './VariablesList.vue'
import ActionsList from './ActionsList.vue'
const props = defineProps({
service: {
type: Object,
required: true
},
deviceUdn: {
type: String,
required: true
}
})
const isExpanded = ref(false)
const activeTab = ref('variables')
const variablesCount = computed(() => {
// Sera mis à jour dynamiquement par VariablesList
return '...'
})
const actionsCount = computed(() => {
return '...'
})
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
function formatServiceType(serviceType) {
const match = serviceType.match(/service:([^:]+)/)
return match ? match[1] : serviceType
}
</script>
<style scoped>
.service-panel {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
transition: all 0.3s;
}
.service-panel:hover {
border-color: rgba(52, 152, 219, 0.6);
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.2);
}
.service-panel.expanded {
border-color: #3498db;
}
.service-header {
display: flex;
align-items: center;
padding: 1rem;
cursor: pointer;
gap: 0.75rem;
transition: background 0.2s;
}
.service-header:hover {
background: rgba(52, 152, 219, 0.1);
}
.service-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.service-info {
flex: 1;
min-width: 0;
}
.service-name {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 600;
color: #ecf0f1;
}
.service-type {
margin: 0;
font-size: 0.8rem;
color: #7f8c8d;
}
.service-badge {
color: #3498db;
font-size: 1rem;
transition: transform 0.3s;
flex-shrink: 0;
}
.service-content {
padding: 0 1rem 1rem 1rem;
}
.service-urls {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 1rem;
}
.url-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
}
.url-item:last-child {
margin-bottom: 0;
}
.url-label {
font-weight: 600;
color: #95a5a6;
min-width: 80px;
font-size: 0.85rem;
}
.url-value {
color: #3498db;
text-decoration: none;
font-size: 0.85rem;
word-break: break-all;
}
.url-value:hover {
text-decoration: underline;
color: #5dade2;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid rgba(52, 152, 219, 0.2);
}
.tab {
flex: 1;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #95a5a6;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-bottom: 3px solid transparent;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.tab:hover {
background: rgba(52, 152, 219, 0.1);
color: #ecf0f1;
}
.tab.active {
color: #3498db;
border-bottom-color: #3498db;
background: rgba(52, 152, 219, 0.05);
}
.badge {
background: rgba(52, 152, 219, 0.3);
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.tab.active .badge {
background: #3498db;
color: white;
}
.tab-content {
min-height: 200px;
}
/* Animations */
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
max-height: 1000px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,546 @@
<template>
<div class="variables-list">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Loading variables...</p>
</div>
<div v-else-if="error" class="error-state">
<span class="error-icon"></span>
<p>{{ error }}</p>
<button @click="loadVariables" class="retry-btn">Retry</button>
</div>
<div v-else-if="variables.length === 0" class="empty-state">
<span class="empty-icon">📭</span>
<p>No variables found for this service</p>
</div>
<div v-else class="variables-content">
<div class="variables-header">
<h4>State Variables ({{ variables.length }})</h4>
<button @click="loadVariables" class="refresh-btn" :disabled="loading">
🔄 Refresh
</button>
</div>
<div class="variables-grid">
<div
v-for="variable in variables"
:key="variable.name"
class="variable-card"
:class="{ 'has-events': variable.sends_events, 'has-value': variable.value }"
>
<div class="variable-header">
<span class="variable-name">{{ variable.name }}</span>
<div class="header-badges">
<span v-if="variable.sends_events" class="event-badge" title="Sends events">
🔔
</span>
<span class="type-badge" :title="variable.data_type">
{{ variable.data_type }}
</span>
</div>
</div>
<div class="variable-details">
<!-- Valeur actuelle - toujours affichée en premier -->
<div class="variable-row current-value">
<span class="variable-label">Current Value:</span>
<div class="value-display">
<code class="variable-value" :class="{ empty: !variable.value }">
{{ variable.value || '(empty)' }}
</code>
<button
v-if="variable.value"
@click="editingVar = editingVar === variable.name ? null : variable.name"
class="edit-btn"
title="Edit value"
>
</button>
</div>
</div>
<!-- Formulaire d'édition -->
<div v-if="editingVar === variable.name" class="edit-form">
<input
v-model="editValue"
:type="getInputType(variable.data_type)"
:placeholder="`Enter ${variable.data_type} value`"
class="edit-input"
@keyup.enter="saveValue(variable)"
@keyup.escape="editingVar = null"
/>
<div class="edit-actions">
<button @click="saveValue(variable)" class="save-btn">💾 Save</button>
<button @click="editingVar = null" class="cancel-btn">✖ Cancel</button>
</div>
</div>
<div v-if="variable.default_value" class="variable-row">
<span class="variable-label">Default:</span>
<code class="variable-value">{{ variable.default_value }}</code>
</div>
<div v-if="variable.allowed_values && variable.allowed_values.length > 0" class="variable-row">
<span class="variable-label">Allowed:</span>
<div class="allowed-values">
<code
v-for="(value, idx) in variable.allowed_values"
:key="idx"
class="allowed-value"
>
{{ value }}
</code>
</div>
</div>
<div v-if="variable.min || variable.max" class="variable-row">
<span class="variable-label">Range:</span>
<code class="variable-value">
{{ variable.min ?? '' }} → {{ variable.max ?? '+' }}
<span v-if="variable.step"> (step: {{ variable.step }})</span>
</code>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const props = defineProps({
deviceUdn: {
type: String,
required: true
},
serviceName: {
type: String,
required: true
}
})
const variables = ref([])
const loading = ref(false)
const error = ref(null)
const editingVar = ref(null)
const editValue = ref('')
function getInputType(dataType) {
if (dataType.includes('int') || dataType.includes('ui')) return 'number'
if (dataType.includes('bool')) return 'checkbox'
return 'text'
}
async function loadVariables() {
if (!props.deviceUdn || !props.serviceName) return
loading.value = true
error.value = null
try {
const url = `/api/upnp/devices/${encodeURIComponent(props.deviceUdn)}/services/${encodeURIComponent(props.serviceName)}/variables`
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
variables.value = data.variables || []
} catch (err) {
error.value = err.message || 'Failed to load variables'
console.error('Error loading variables:', err)
} finally {
loading.value = false
}
}
async function saveValue(variable) {
// TODO: Implement API call to update variable value
console.log(`Saving ${variable.name} = ${editValue.value}`)
editingVar.value = null
editValue.value = ''
// Refresh to get updated value
await loadVariables()
}
// Load on mount
onMounted(() => {
loadVariables()
})
// Reload when props change
watch(() => [props.deviceUdn, props.serviceName], () => {
loadVariables()
})
// Set edit value when starting to edit
watch(editingVar, (newVar) => {
if (newVar) {
const variable = variables.value.find(v => v.name === newVar)
if (variable) {
editValue.value = variable.value || ''
}
}
})
</script>
<style scoped>
.variables-list {
min-height: 200px;
display: flex;
flex-direction: column;
}
/* Loading state */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #95a5a6;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(52, 152, 219, 0.3);
border-top-color: #3498db;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error state */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #e74c3c;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-state p {
margin: 0 0 1rem 0;
color: #ecf0f1;
}
.retry-btn {
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.retry-btn:hover {
background: #c0392b;
transform: translateY(-2px);
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #95a5a6;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
margin: 0;
}
/* Variables content */
.variables-content {
flex: 1;
}
.variables-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1rem;
}
.variables-header h4 {
margin: 0;
color: #ecf0f1;
font-size: 1rem;
font-weight: 600;
}
.refresh-btn {
padding: 0.5rem 1rem;
background: rgba(52, 152, 219, 0.2);
color: #3498db;
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.refresh-btn:hover:not(:disabled) {
background: rgba(52, 152, 219, 0.3);
border-color: #3498db;
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variables grid */
.variables-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.variable-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 8px;
padding: 1rem;
transition: all 0.2s;
}
.variable-card:hover {
border-color: rgba(52, 152, 219, 0.6);
background: rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.variable-card.has-events {
border-color: rgba(46, 204, 113, 0.4);
}
.variable-card.has-events:hover {
border-color: rgba(46, 204, 113, 0.7);
}
.variable-card.has-value {
border-left: 3px solid #3498db;
}
.variable-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(52, 152, 219, 0.2);
}
.variable-name {
font-weight: 600;
color: #3498db;
font-size: 0.95rem;
}
.header-badges {
display: flex;
gap: 0.5rem;
align-items: center;
}
.event-badge {
font-size: 1rem;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.type-badge {
padding: 0.2rem 0.5rem;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 4px;
color: #9b59b6;
font-size: 0.75rem;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.variable-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.variable-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.variable-row.current-value {
background: rgba(52, 152, 219, 0.1);
padding: 0.5rem;
border-radius: 4px;
border-left: 3px solid #3498db;
}
.variable-label {
font-size: 0.75rem;
color: #95a5a6;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.value-display {
display: flex;
align-items: center;
gap: 0.5rem;
}
.variable-value {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #ecf0f1;
background: rgba(0, 0, 0, 0.3);
padding: 0.3rem 0.6rem;
border-radius: 4px;
flex: 1;
}
.variable-value.empty {
color: #7f8c8d;
font-style: italic;
}
.edit-btn {
padding: 0.3rem 0.5rem;
background: rgba(241, 196, 15, 0.2);
border: 1px solid rgba(241, 196, 15, 0.3);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.edit-btn:hover {
background: rgba(241, 196, 15, 0.3);
border-color: #f1c40f;
}
/* Edit form */
.edit-form {
background: rgba(241, 196, 15, 0.1);
padding: 0.75rem;
border-radius: 4px;
border: 1px solid rgba(241, 196, 15, 0.3);
margin-top: 0.5rem;
}
.edit-input {
width: 100%;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(241, 196, 15, 0.3);
border-radius: 4px;
color: #ecf0f1;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.edit-input:focus {
outline: none;
border-color: #f1c40f;
background: rgba(0, 0, 0, 0.4);
}
.edit-actions {
display: flex;
gap: 0.5rem;
}
.save-btn,
.cancel-btn {
flex: 1;
padding: 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
}
.save-btn {
background: #27ae60;
color: white;
}
.save-btn:hover {
background: #229954;
}
.cancel-btn {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
border: 1px solid rgba(231, 76, 60, 0.3);
}
.cancel-btn:hover {
background: rgba(231, 76, 60, 0.3);
border-color: #e74c3c;
}
.allowed-values {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.allowed-value {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
padding: 0.2rem 0.4rem;
border-radius: 4px;
border: 1px solid rgba(46, 204, 113, 0.3);
}
/* Responsive */
@media (max-width: 768px) {
.variables-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -2,11 +2,13 @@ import { createRouter, createWebHistory } from "vue-router";
import HelloWorld from "../components/HelloWorld.vue";
import LogView from "../components/LogView.vue";
import CoverCacheManager from "../components/CoverCacheManager.vue";
import UpnpExplorer from "../components/UpnpExplorer.vue";
const routes = [
{ path: "/", name: "home", component: HelloWorld },
{ path: "/logs", name: "logs", component: LogView },
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
];
const router = createRouter({

View File

@@ -403,4 +403,29 @@ impl StateVariable {
pub fn unset_value_marshaler(&mut self) {
self.marshal = None;
}
/// Retourne le type de données de cette variable.
pub fn get_data_type(&self) -> &StateVarType {
&self.value_type
}
/// Retourne la valeur par défaut si définie.
pub fn get_default_value(&self) -> Option<&StateValue> {
self.default_value.as_ref()
}
/// Retourne le step si défini.
pub fn get_step(&self) -> Option<&StateValue> {
self.step.as_ref()
}
/// Retourne les valeurs autorisées.
pub fn get_allowed_values(&self) -> Vec<StateValue> {
self.allowed_values.read().unwrap().clone()
}
/// Indique si cette variable envoie des notifications d'événements.
pub fn sends_events(&self) -> bool {
self.send_events
}
}

View File

@@ -64,6 +64,45 @@ async fn get_device(Path(udn): Path<String>) -> impl IntoResponse {
.services()
.iter()
.map(|s| {
// Collecter les actions
let actions: Vec<_> = s.actions()
.all()
.iter()
.map(|a| {
let all_args = a.arguments_set().all();
let in_args: Vec<_> = all_args
.iter()
.filter(|arg| arg.get_model().is_in())
.map(|arg| {
let model = arg.get_model();
json!({
"name": arg.get_name(),
"related_state_variable": model.state_variable().get_name()
})
})
.collect();
let out_args: Vec<_> = all_args
.iter()
.filter(|arg| arg.get_model().is_out())
.map(|arg| {
let model = arg.get_model();
json!({
"name": arg.get_name(),
"related_state_variable": model.state_variable().get_name()
})
})
.collect();
json!({
"name": a.get_name(),
"in_arguments": in_args,
"out_arguments": out_args
})
})
.collect();
json!({
"name": s.get_name(),
"service_type": s.service_type(),
@@ -71,6 +110,7 @@ async fn get_device(Path(udn): Path<String>) -> impl IntoResponse {
"control_url": format!("{}{}", device.base_url(), s.control_route()),
"event_url": format!("{}{}", device.base_url(), s.event_route()),
"scpd_url": format!("{}{}", device.base_url(), s.scpd_route()),
"actions": actions
})
})
.collect();
@@ -112,10 +152,38 @@ async fn get_service_variables(Path((udn, service_name)): Path<(String, String)>
.all()
.iter()
.map(|v| {
let model = v.get_model();
// Obtenir les allowed values
let allowed_values = {
let av = model.get_allowed_values();
if av.is_empty() {
None
} else {
Some(av.iter().map(|val| val.to_string()).collect::<Vec<_>>())
}
};
// Accéder au range si défini
let (min, max) = if let Some(range) = model.get_range() {
(
Some(range.get_minimum().to_string()),
Some(range.get_maximum().to_string())
)
} else {
(None, None)
};
json!({
"name": v.get_name(),
"value": v.value().to_string(),
"data_type": model.get_data_type().to_string(),
"sends_events": v.is_sending_notification(),
"default_value": model.get_default_value().map(|dv| dv.to_string()),
"allowed_values": allowed_values,
"min": min,
"max": max,
"step": model.get_step().map(|s| s.to_string()),
})
})
.collect();
@@ -168,9 +236,9 @@ impl UpnpApiExt for Server {
// Créer le routeur Axum
let app = Router::new()
.route("/devices", get(list_devices))
.route("/devices/:udn", get(get_device))
.route("/devices/{udn}", get(get_device))
.route(
"/devices/:udn/services/:service/variables",
"/devices/{udn}/services/{service}/variables",
get(get_service_variables),
);

View File

@@ -19,7 +19,8 @@
//! ```
use std::sync::Arc;
use std::cell::RefCell;
use std::sync::RwLock;
use once_cell::sync::Lazy;
use pmoserver::Server;
@@ -27,13 +28,14 @@ use crate::devices::errors::DeviceError;
use crate::devices::{Device, DeviceInstance, DeviceRegistry};
use crate::UpnpModel;
thread_local! {
/// Registre de devices thread-local.
///
/// Permet de maintenir un registre de devices par thread/serveur
/// sans modifier la structure `pmoserver::Server`.
static DEVICE_REGISTRY: RefCell<DeviceRegistry> = RefCell::new(DeviceRegistry::new());
}
/// Registre de devices global et thread-safe.
///
/// Utilise Lazy pour une initialisation paresseuse et RwLock pour le partage entre threads.
/// Ceci permet aux API handlers (qui s'exécutent dans des threads différents) d'accéder
/// au même registre de devices.
static DEVICE_REGISTRY: Lazy<RwLock<DeviceRegistry>> = Lazy::new(|| {
RwLock::new(DeviceRegistry::new())
});
/// Trait pour étendre un serveur avec des fonctionnalités UPnP.
///
@@ -99,31 +101,30 @@ impl UpnpServer for Server {
di.register_urls(self).await?;
// Ajouter au registre pour l'introspection
DEVICE_REGISTRY.with(|registry| {
registry.borrow_mut()
.register(di.clone())
.map_err(|e| DeviceError::UrlRegistrationError(e))
})?;
DEVICE_REGISTRY.write()
.unwrap()
.register(di.clone())
.map_err(|e| DeviceError::UrlRegistrationError(e))?;
Ok(di)
}
fn device_count(&self) -> usize {
DEVICE_REGISTRY.with(|registry| registry.borrow().count())
DEVICE_REGISTRY.read().unwrap().count()
}
fn list_devices(&self) -> Vec<Arc<DeviceInstance>> {
DEVICE_REGISTRY.with(|registry| registry.borrow().list_devices())
DEVICE_REGISTRY.read().unwrap().list_devices()
}
fn get_device(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
DEVICE_REGISTRY.with(|registry| registry.borrow().get_device(udn))
DEVICE_REGISTRY.read().unwrap().get_device(udn)
}
}
/// Fonctions helper pour accéder au registre depuis les handlers.
///
/// Ces fonctions permettent d'accéder au registre thread-local depuis
/// Ces fonctions permettent d'accéder au registre global depuis
/// n'importe où dans le code, notamment depuis les handlers Axum.
/// Exécute une closure avec un accès en lecture seule aux devices.
@@ -139,10 +140,8 @@ pub fn with_devices<F, R>(f: F) -> R
where
F: FnOnce(&Vec<Arc<DeviceInstance>>) -> R,
{
DEVICE_REGISTRY.with(|registry| {
let devices = registry.borrow().list_devices();
f(&devices)
})
let devices = DEVICE_REGISTRY.read().unwrap().list_devices();
f(&devices)
}
/// Récupère un device par son UDN.
@@ -157,7 +156,7 @@ where
/// }
/// ```
pub fn get_device_by_udn(udn: &str) -> Option<Arc<DeviceInstance>> {
DEVICE_REGISTRY.with(|registry| registry.borrow().get_device(udn))
DEVICE_REGISTRY.read().unwrap().get_device(udn)
}
#[cfg(test)]