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:
@@ -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>
|
||||
|
||||
421
pmoapp/webapp/src/components/UpnpExplorer.vue
Normal file
421
pmoapp/webapp/src/components/UpnpExplorer.vue
Normal 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>
|
||||
491
pmoapp/webapp/src/components/upnp/ActionsList.vue
Normal file
491
pmoapp/webapp/src/components/upnp/ActionsList.vue
Normal 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>
|
||||
307
pmoapp/webapp/src/components/upnp/DeviceCard.vue
Normal file
307
pmoapp/webapp/src/components/upnp/DeviceCard.vue
Normal 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>
|
||||
281
pmoapp/webapp/src/components/upnp/ServicePanel.vue
Normal file
281
pmoapp/webapp/src/components/upnp/ServicePanel.vue
Normal 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>
|
||||
546
pmoapp/webapp/src/components/upnp/VariablesList.vue
Normal file
546
pmoapp/webapp/src/components/upnp/VariablesList.vue
Normal 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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user