test communication scheduler<->simclient
This commit is contained in:
20
dashboard/package-lock.json
generated
20
dashboard/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@syncfusion/ej2-react-grids": "^30.1.37",
|
"@syncfusion/ej2-react-grids": "^30.1.37",
|
||||||
"@syncfusion/ej2-react-inputs": "^30.1.38",
|
"@syncfusion/ej2-react-inputs": "^30.1.38",
|
||||||
"@syncfusion/ej2-react-kanban": "^30.1.37",
|
"@syncfusion/ej2-react-kanban": "^30.1.37",
|
||||||
|
"@syncfusion/ej2-react-layouts": "^30.1.40",
|
||||||
"@syncfusion/ej2-react-notifications": "^30.1.37",
|
"@syncfusion/ej2-react-notifications": "^30.1.37",
|
||||||
"@syncfusion/ej2-react-popups": "^30.1.37",
|
"@syncfusion/ej2-react-popups": "^30.1.37",
|
||||||
"@syncfusion/ej2-react-schedule": "^30.1.37",
|
"@syncfusion/ej2-react-schedule": "^30.1.37",
|
||||||
@@ -1165,12 +1166,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@syncfusion/ej2-layouts": {
|
"node_modules/@syncfusion/ej2-layouts": {
|
||||||
"version": "30.1.37",
|
"version": "30.1.40",
|
||||||
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-layouts/-/ej2-layouts-30.1.37.tgz",
|
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-layouts/-/ej2-layouts-30.1.40.tgz",
|
||||||
"integrity": "sha512-EPI00OSBMuzxp8od6jTOK2LRlv5IWtN4WmpIklUe34vev0qG9HLo8yT2BKCUIo5TRm4xEUXx0mQM7ZSKw2iHig==",
|
"integrity": "sha512-PPv+brJOOkaMp+HZ7IDq7Tc8aYMYrpP7i0cp/b2W8fFTwcXoI06l4oRp65NAy1THnU82m2pRnbTETyrSDMu+TA==",
|
||||||
"license": "SEE LICENSE IN license",
|
"license": "SEE LICENSE IN license",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@syncfusion/ej2-base": "~30.1.37"
|
"@syncfusion/ej2-base": "~30.1.38"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@syncfusion/ej2-lists": {
|
"node_modules/@syncfusion/ej2-lists": {
|
||||||
@@ -1315,6 +1316,17 @@
|
|||||||
"@syncfusion/ej2-react-base": "~30.1.37"
|
"@syncfusion/ej2-react-base": "~30.1.37"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@syncfusion/ej2-react-layouts": {
|
||||||
|
"version": "30.1.40",
|
||||||
|
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-layouts/-/ej2-react-layouts-30.1.40.tgz",
|
||||||
|
"integrity": "sha512-gOIo3DOEy+m0QF8vuj+dEB/PdnEnv4n+8pvCIPfiCcatY46rPSv4Lzh8q4PixGfFwauLfljAIdm+3gquxvfJIA==",
|
||||||
|
"license": "SEE LICENSE IN license",
|
||||||
|
"dependencies": {
|
||||||
|
"@syncfusion/ej2-base": "~30.1.38",
|
||||||
|
"@syncfusion/ej2-layouts": "30.1.40",
|
||||||
|
"@syncfusion/ej2-react-base": "~30.1.37"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@syncfusion/ej2-react-notifications": {
|
"node_modules/@syncfusion/ej2-react-notifications": {
|
||||||
"version": "30.1.37",
|
"version": "30.1.37",
|
||||||
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.1.37.tgz",
|
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.1.37.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@syncfusion/ej2-react-grids": "^30.1.37",
|
"@syncfusion/ej2-react-grids": "^30.1.37",
|
||||||
"@syncfusion/ej2-react-inputs": "^30.1.38",
|
"@syncfusion/ej2-react-inputs": "^30.1.38",
|
||||||
"@syncfusion/ej2-react-kanban": "^30.1.37",
|
"@syncfusion/ej2-react-kanban": "^30.1.37",
|
||||||
|
"@syncfusion/ej2-react-layouts": "^30.1.40",
|
||||||
"@syncfusion/ej2-react-notifications": "^30.1.37",
|
"@syncfusion/ej2-react-notifications": "^30.1.37",
|
||||||
"@syncfusion/ej2-react-popups": "^30.1.37",
|
"@syncfusion/ej2-react-popups": "^30.1.37",
|
||||||
"@syncfusion/ej2-react-schedule": "^30.1.37",
|
"@syncfusion/ej2-react-schedule": "^30.1.37",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const sidebarItems = [
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { fetchClientsWithoutDescription } from './apiClients';
|
|
||||||
|
|
||||||
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
|
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
|
||||||
const ENV = import.meta.env.VITE_ENV || 'development';
|
const ENV = import.meta.env.VITE_ENV || 'development';
|
||||||
@@ -128,7 +127,6 @@ const Layout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function useLoginCheck() {
|
function useLoginCheck() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
|
||||||
@@ -150,17 +148,7 @@ const App: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isLoggedIn = useLoginCheck();
|
const isLoggedIn = useLoginCheck();
|
||||||
|
|
||||||
useEffect(() => {
|
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
|
||||||
if (!isLoggedIn) return;
|
|
||||||
fetchClientsWithoutDescription().then(list => {
|
|
||||||
if (list.length > 0) {
|
|
||||||
console.log('[Navigation] Weiterleitung zu /clients wegen fehlender Beschreibung');
|
|
||||||
navigate('/clients');
|
|
||||||
} else {
|
|
||||||
console.log('[Navigation] Dashboard wird angezeigt, alle Clients haben Beschreibung');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [isLoggedIn, navigate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
hardware_token?: string;
|
hardware_token?: string;
|
||||||
@@ -51,3 +50,13 @@ export async function updateClientGroup(clientIds: string[], groupName: string)
|
|||||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
|
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateClient(uuid: string, data: { description?: string; model?: string }) {
|
||||||
|
const res = await fetch(`/api/clients/${uuid}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients');
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,108 @@
|
|||||||
import SetupModeButton from './components/SetupModeButton';
|
import SetupModeButton from './components/SetupModeButton';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { fetchClients, fetchClientsWithoutDescription, setClientDescription } from './apiClients';
|
// ...ButtonComponent entfernt...
|
||||||
|
// Card-Komponente wird als eigenes Layout umgesetzt
|
||||||
|
import { fetchClients, updateClient } from './apiClients';
|
||||||
import type { Client } from './apiClients';
|
import type { Client } from './apiClients';
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
ColumnsDirective,
|
||||||
|
ColumnDirective,
|
||||||
|
Page,
|
||||||
|
Inject,
|
||||||
|
Toolbar,
|
||||||
|
Search,
|
||||||
|
Sort,
|
||||||
|
Edit,
|
||||||
|
} from '@syncfusion/ej2-react-grids';
|
||||||
|
|
||||||
// Dummy Modalbox (ersetzbar durch SyncFusion Dialog)
|
// Raumgruppen werden dynamisch aus der API geladen
|
||||||
function ModalBox({ open, onClose }) {
|
|
||||||
if (!open) return null;
|
interface DetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
client: Client | null;
|
||||||
|
groupIdToName: Record<string | number, string>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProps) {
|
||||||
|
if (!open || !client) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.3)', zIndex: 1000 }}>
|
<div
|
||||||
<div style={{ background: 'white', padding: 32, margin: '100px auto', maxWidth: 400, borderRadius: 8 }}>
|
style={{
|
||||||
<h3>Neue Clients ohne Beschreibung!</h3>
|
position: 'fixed',
|
||||||
<p>Bitte ergänzen Sie die Beschreibung für neue Clients.</p>
|
top: 0,
|
||||||
<button onClick={onClose}>Speichern (Dummy)</button>
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.3)',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
padding: 0,
|
||||||
|
margin: '100px auto',
|
||||||
|
maxWidth: 500,
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: 18 }}>Client-Details</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 24 }}>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(client)
|
||||||
|
.filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
'index',
|
||||||
|
'is_active',
|
||||||
|
'type',
|
||||||
|
'column',
|
||||||
|
'group_name',
|
||||||
|
'foreignKeyData',
|
||||||
|
'hardware_token',
|
||||||
|
].includes(key)
|
||||||
|
)
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<tr key={key}>
|
||||||
|
<td style={{ fontWeight: 'bold', padding: '6px 8px' }}>
|
||||||
|
{key === 'group_id'
|
||||||
|
? 'Raumgruppe'
|
||||||
|
: key === 'ip'
|
||||||
|
? 'IP-Adresse'
|
||||||
|
: key === 'registration_time'
|
||||||
|
? 'Registriert am'
|
||||||
|
: key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
:
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>
|
||||||
|
{key === 'group_id'
|
||||||
|
? value !== undefined
|
||||||
|
? groupIdToName[value as string | number] || value
|
||||||
|
: ''
|
||||||
|
: key === 'registration_time' && value
|
||||||
|
? new Date(
|
||||||
|
(value as string).endsWith('Z') ? (value as string) : value + 'Z'
|
||||||
|
).toLocaleString()
|
||||||
|
: key === 'last_alive' && value
|
||||||
|
? new Date(
|
||||||
|
(value as string).endsWith('Z') ? (value as string) : value + 'Z'
|
||||||
|
).toLocaleString()
|
||||||
|
: String(value)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<button className="e-btn e-outline" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -19,42 +110,122 @@ function ModalBox({ open, onClose }) {
|
|||||||
|
|
||||||
const Clients: React.FC = () => {
|
const Clients: React.FC = () => {
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
|
||||||
|
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClients().then(setClients);
|
fetchClients().then(setClients);
|
||||||
fetchClientsWithoutDescription().then(list => {
|
// Gruppen auslesen
|
||||||
if (list.length > 0) setShowModal(true);
|
import('./apiGroups').then(mod => mod.fetchGroups()).then(setGroups);
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Map group_id zu group_name
|
||||||
|
const groupIdToName: Record<string | number, string> = {};
|
||||||
|
groups.forEach(g => {
|
||||||
|
groupIdToName[g.id] = g.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DataGrid data mit korrektem Gruppennamen und formatierten Zeitangaben
|
||||||
|
const gridData = clients.map(c => ({
|
||||||
|
...c,
|
||||||
|
group_name: c.group_id !== undefined ? groupIdToName[c.group_id] || String(c.group_id) : '',
|
||||||
|
last_alive: c.last_alive
|
||||||
|
? new Date(
|
||||||
|
(c.last_alive as string).endsWith('Z') ? (c.last_alive as string) : c.last_alive + 'Z'
|
||||||
|
).toLocaleString()
|
||||||
|
: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DataGrid row template für Details-Button
|
||||||
|
const detailsButtonTemplate = (props: Client) => (
|
||||||
|
<button
|
||||||
|
className="e-btn e-primary"
|
||||||
|
onClick={() => setSelectedClient(props)}
|
||||||
|
style={{ minWidth: 80 }}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold mb-4">Client-Übersicht</h2>
|
<div className="flex justify-between items-center mb-4">
|
||||||
<table className="min-w-full border">
|
<h2 className="text-xl font-bold">Client-Übersicht</h2>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>UUID</th>
|
|
||||||
<th>Hostname</th>
|
|
||||||
<th>Beschreibung</th>
|
|
||||||
<th>Gruppe</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clients.map(c => (
|
|
||||||
<tr key={c.uuid}>
|
|
||||||
<td>{c.uuid}</td>
|
|
||||||
<td>{c.hostname}</td>
|
|
||||||
<td>{c.description}</td>
|
|
||||||
<td>{c.group_id}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mb-4">
|
|
||||||
<SetupModeButton />
|
<SetupModeButton />
|
||||||
</div>
|
</div>
|
||||||
<ModalBox open={showModal} onClose={() => setShowModal(false)} />
|
{groups.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<GridComponent
|
||||||
|
dataSource={gridData}
|
||||||
|
allowPaging={true}
|
||||||
|
pageSettings={{ pageSize: 10 }}
|
||||||
|
toolbar={['Search', 'Edit', 'Update', 'Cancel']}
|
||||||
|
allowSorting={true}
|
||||||
|
allowFiltering={true}
|
||||||
|
height={400}
|
||||||
|
editSettings={{
|
||||||
|
allowEditing: true,
|
||||||
|
allowAdding: false,
|
||||||
|
allowDeleting: false,
|
||||||
|
mode: 'Normal',
|
||||||
|
}}
|
||||||
|
actionComplete={async (args: { requestType: string; data: any }) => {
|
||||||
|
if (args.requestType === 'save') {
|
||||||
|
const { uuid, description, model } = args.data;
|
||||||
|
// API-Aufruf zum Speichern
|
||||||
|
await updateClient(uuid, { description, model });
|
||||||
|
// Nach dem Speichern neu laden
|
||||||
|
fetchClients().then(setClients);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective
|
||||||
|
field="description"
|
||||||
|
headerText="Beschreibung"
|
||||||
|
width="150"
|
||||||
|
allowEditing={true}
|
||||||
|
/>
|
||||||
|
<ColumnDirective
|
||||||
|
field="group_name"
|
||||||
|
headerText="Raumgruppe"
|
||||||
|
width="120"
|
||||||
|
allowEditing={false}
|
||||||
|
/>
|
||||||
|
<ColumnDirective field="uuid" headerText="UUID" width="220" allowEditing={false} />
|
||||||
|
<ColumnDirective
|
||||||
|
field="ip"
|
||||||
|
headerText="IP-Adresse"
|
||||||
|
width="150"
|
||||||
|
allowEditing={false}
|
||||||
|
/>
|
||||||
|
<ColumnDirective
|
||||||
|
field="last_alive"
|
||||||
|
headerText="Last Alive"
|
||||||
|
width="120"
|
||||||
|
allowEditing={false}
|
||||||
|
/>
|
||||||
|
<ColumnDirective field="model" headerText="Model" width="120" allowEditing={true} />
|
||||||
|
<ColumnDirective
|
||||||
|
headerText="Details"
|
||||||
|
width="100"
|
||||||
|
template={detailsButtonTemplate}
|
||||||
|
textAlign="Center"
|
||||||
|
allowEditing={false}
|
||||||
|
/>
|
||||||
|
</ColumnsDirective>
|
||||||
|
<Inject services={[Page, Toolbar, Search, Sort, Edit]} />
|
||||||
|
</GridComponent>
|
||||||
|
<DetailsModal
|
||||||
|
open={!!selectedClient}
|
||||||
|
client={selectedClient}
|
||||||
|
groupIdToName={groupIdToName}
|
||||||
|
onClose={() => setSelectedClient(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500">Raumgruppen werden geladen ...</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
# ...requests entfernt...
|
||||||
|
import datetime
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from sqlalchemy import create_engine, func
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from models.models import Client
|
from models.models import Client
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -17,19 +21,21 @@ DB_URL = os.environ.get(
|
|||||||
"DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
|
"DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
logging.basicConfig(level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
logging.basicConfig(level=logging.DEBUG,
|
||||||
format='%(asctime)s [%(levelname)s] %(message)s')
|
format='%(asctime)s [%(levelname)s] %(message)s')
|
||||||
|
|
||||||
# DB-Konfiguration
|
# DB-Konfiguration
|
||||||
engine = create_engine(DB_URL)
|
engine = create_engine(DB_URL)
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
# ...externe Zeitsynchronisation entfernt...
|
||||||
|
|
||||||
# MQTT-Callback
|
# MQTT-Callback
|
||||||
|
|
||||||
|
|
||||||
def on_message(client, userdata, msg):
|
def on_message(client, userdata, msg):
|
||||||
topic = msg.topic
|
topic = msg.topic
|
||||||
|
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
|
||||||
try:
|
try:
|
||||||
# Heartbeat-Handling
|
# Heartbeat-Handling
|
||||||
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
|
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
|
||||||
@@ -37,11 +43,10 @@ def on_message(client, userdata, msg):
|
|||||||
session = Session()
|
session = Session()
|
||||||
client_obj = session.query(Client).filter_by(uuid=uuid).first()
|
client_obj = session.query(Client).filter_by(uuid=uuid).first()
|
||||||
if client_obj:
|
if client_obj:
|
||||||
from sqlalchemy import func
|
client_obj.last_alive = datetime.datetime.now(datetime.UTC)
|
||||||
client_obj.last_alive = func.current_timestamp()
|
|
||||||
session.commit()
|
session.commit()
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Heartbeat von {uuid} empfangen, last_alive aktualisiert.")
|
f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.")
|
||||||
session.close()
|
session.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -62,6 +67,7 @@ def on_message(client, userdata, msg):
|
|||||||
software_version=payload.get("software_version"),
|
software_version=payload.get("software_version"),
|
||||||
macs=",".join(payload.get("macs", [])),
|
macs=",".join(payload.get("macs", [])),
|
||||||
model=payload.get("model"),
|
model=payload.get("model"),
|
||||||
|
registration_time=datetime.datetime.now(datetime.UTC),
|
||||||
)
|
)
|
||||||
session.add(new_client)
|
session.add(new_client)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -1,40 +1,74 @@
|
|||||||
# scheduler/scheduler.py
|
# scheduler/scheduler.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
from scheduler.db_utils import get_active_events
|
from scheduler.db_utils import get_active_events
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
# Logging-Konfiguration
|
||||||
|
ENV = os.getenv("ENV", "development")
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO")
|
||||||
|
LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log")
|
||||||
|
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
||||||
|
log_handlers = []
|
||||||
|
if ENV == "production":
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
log_handlers.append(RotatingFileHandler(
|
||||||
|
LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
log_handlers.append(logging.FileHandler(LOG_PATH, encoding="utf-8"))
|
||||||
|
if os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True"):
|
||||||
|
log_handlers.append(logging.StreamHandler())
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=log_handlers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Fix für die veraltete API - explizit callback_api_version setzen
|
# Fix für die veraltete API - explizit callback_api_version setzen
|
||||||
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
||||||
client.connect("mqtt", 1883)
|
client.connect("mqtt", 1883)
|
||||||
|
|
||||||
POLL_INTERVAL = 10 # Sekunden
|
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
|
||||||
last_sent_event_ids = set()
|
last_payloads = {} # group_id -> payload
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
# Hole alle Events, die jetzt aktiv sind (start < now < end)
|
# Hole alle aktiven Events (Vergleich mit UTC)
|
||||||
events = get_active_events(now, now)
|
events = get_active_events(now, now)
|
||||||
current_event_ids = set(event.id for event in events)
|
# Gruppiere Events nach group_id
|
||||||
|
groups = {}
|
||||||
# Sende nur neue Events (die noch nicht gesendet wurden)
|
|
||||||
new_event_ids = current_event_ids - last_sent_event_ids
|
|
||||||
for event in events:
|
for event in events:
|
||||||
if event.id in new_event_ids:
|
gid = getattr(event, "group_id", None)
|
||||||
# Beispiel: Sende Event-Daten als JSON auf Topic "infoscreen/events"
|
if gid not in groups:
|
||||||
payload = json.dumps({
|
groups[gid] = []
|
||||||
|
groups[gid].append({
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
"title": getattr(event, "title", ""),
|
"title": getattr(event, "title", ""),
|
||||||
"start": str(getattr(event, "start", "")),
|
"start": str(getattr(event, "start", "")),
|
||||||
"end": str(getattr(event, "end", "")),
|
"end": str(getattr(event, "end", "")),
|
||||||
"group_id": getattr(event, "group_id", None),
|
"group_id": gid,
|
||||||
})
|
})
|
||||||
client.publish("infoscreen/events", payload)
|
# Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung
|
||||||
print(f"Event {event.id} gesendet: {payload}")
|
for gid, event_list in groups.items():
|
||||||
|
topic = f"infoscreen/events/{gid}"
|
||||||
last_sent_event_ids = current_event_ids
|
payload = json.dumps(event_list)
|
||||||
|
if last_payloads.get(gid) != payload:
|
||||||
|
client.publish(topic, payload, retain=True)
|
||||||
|
logging.info(f"Events für Gruppe {gid} gesendet: {payload}")
|
||||||
|
last_payloads[gid] = payload
|
||||||
|
# Entferne Gruppen, die nicht mehr existieren (optional: retained Message löschen)
|
||||||
|
for gid in list(last_payloads.keys()):
|
||||||
|
if gid not in groups:
|
||||||
|
topic = f"infoscreen/events/{gid}"
|
||||||
|
client.publish(topic, payload="[]", retain=True)
|
||||||
|
logging.info(
|
||||||
|
f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)")
|
||||||
|
del last_payloads[gid]
|
||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ DB_HOST = os.getenv("DB_HOST", "db")
|
|||||||
DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa")
|
DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa")
|
||||||
|
|
||||||
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
|
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
|
||||||
|
print(f"Using DB_URL: {DB_URL}") # Debug-Ausgabe
|
||||||
engine = create_engine(DB_URL, echo=False)
|
engine = create_engine(DB_URL, echo=False)
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
@@ -6,6 +6,7 @@ sys.path.append('/workspace')
|
|||||||
|
|
||||||
clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
|
clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
|
||||||
|
|
||||||
|
|
||||||
@clients_bp.route("/without_description", methods=["GET"])
|
@clients_bp.route("/without_description", methods=["GET"])
|
||||||
def get_clients_without_description():
|
def get_clients_without_description():
|
||||||
session = Session()
|
session = Session()
|
||||||
@@ -50,6 +51,7 @@ def set_client_description(uuid):
|
|||||||
session.close()
|
session.close()
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
@clients_bp.route("", methods=["GET"])
|
@clients_bp.route("", methods=["GET"])
|
||||||
def get_clients():
|
def get_clients():
|
||||||
session = Session()
|
session = Session()
|
||||||
@@ -93,3 +95,39 @@ def update_clients_group():
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@clients_bp.route("/<uuid>", methods=["PATCH"])
|
||||||
|
def update_client(uuid):
|
||||||
|
data = request.get_json()
|
||||||
|
session = Session()
|
||||||
|
client = session.query(Client).filter_by(uuid=uuid).first()
|
||||||
|
if not client:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Client nicht gefunden"}), 404
|
||||||
|
allowed_fields = ["description", "model"]
|
||||||
|
updated = False
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in data:
|
||||||
|
setattr(client, field, data[field])
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
session.commit()
|
||||||
|
result = {"success": True}
|
||||||
|
else:
|
||||||
|
result = {"error": "Keine gültigen Felder zum Aktualisieren übergeben"}
|
||||||
|
session.close()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# Neue Route: Liefert die aktuelle group_id für einen Client
|
||||||
|
@clients_bp.route("/<uuid>/group", methods=["GET"])
|
||||||
|
def get_client_group(uuid):
|
||||||
|
session = Session()
|
||||||
|
client = session.query(Client).filter_by(uuid=uuid).first()
|
||||||
|
if not client:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Client nicht gefunden"}), 404
|
||||||
|
group_id = client.group_id
|
||||||
|
session.close()
|
||||||
|
return jsonify({"group_id": group_id})
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
paho-mqtt
|
paho-mqtt
|
||||||
dotenv
|
dotenv
|
||||||
|
requests
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# simclient/simclient.py
|
# simclient/simclient.py
|
||||||
|
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
@@ -11,6 +12,7 @@ import re
|
|||||||
import platform
|
import platform
|
||||||
import logging
|
import logging
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import requests
|
||||||
|
|
||||||
# ENV laden
|
# ENV laden
|
||||||
load_dotenv("/workspace/simclient/.env")
|
load_dotenv("/workspace/simclient/.env")
|
||||||
@@ -26,8 +28,11 @@ DEBUG_MODE = os.getenv("DEBUG_MODE", "1" if ENV ==
|
|||||||
"development" else "0") in ("1", "true", "True")
|
"development" else "0") in ("1", "true", "True")
|
||||||
|
|
||||||
# Logging-Konfiguration
|
# Logging-Konfiguration
|
||||||
LOG_PATH = "/tmp/simclient.log"
|
LOG_PATH = os.path.join(os.path.dirname(__file__), "simclient.log")
|
||||||
log_handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")]
|
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
||||||
|
log_handlers = []
|
||||||
|
log_handlers.append(RotatingFileHandler(
|
||||||
|
LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8"))
|
||||||
if DEBUG_MODE:
|
if DEBUG_MODE:
|
||||||
log_handlers.append(logging.StreamHandler())
|
log_handlers.append(logging.StreamHandler())
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -43,6 +48,10 @@ discovered = False
|
|||||||
def on_message(client, userdata, msg):
|
def on_message(client, userdata, msg):
|
||||||
global discovered
|
global discovered
|
||||||
logging.info(f"Empfangen: {msg.topic} {msg.payload.decode()}")
|
logging.info(f"Empfangen: {msg.topic} {msg.payload.decode()}")
|
||||||
|
# Event-Messages vom Scheduler explizit loggen
|
||||||
|
if msg.topic.startswith("infoscreen/events/"):
|
||||||
|
logging.info(
|
||||||
|
f"Event-Message vom Scheduler empfangen: {msg.payload.decode()}")
|
||||||
# ACK-Quittung empfangen?
|
# ACK-Quittung empfangen?
|
||||||
if msg.topic.endswith("/discovery_ack"):
|
if msg.topic.endswith("/discovery_ack"):
|
||||||
discovered = True
|
discovered = True
|
||||||
@@ -168,14 +177,45 @@ def main():
|
|||||||
client.subscribe(ack_topic)
|
client.subscribe(ack_topic)
|
||||||
client.subscribe(f"infoscreen/{client_id}/config")
|
client.subscribe(f"infoscreen/{client_id}/config")
|
||||||
|
|
||||||
|
# Hilfsfunktion: Hole group_id per API
|
||||||
|
def get_group_id(client_id):
|
||||||
|
api_url = f"http://server:8000/api/clients/{client_id}/group"
|
||||||
|
try:
|
||||||
|
resp = requests.get(api_url, timeout=5)
|
||||||
|
logging.debug(
|
||||||
|
f"API-Request: {api_url} - Status: {resp.status_code} - Response: {resp.text}")
|
||||||
|
if resp.ok:
|
||||||
|
group_id = resp.json().get("group_id")
|
||||||
|
logging.info(
|
||||||
|
f"Ermittelte group_id für Client {client_id}: {group_id}")
|
||||||
|
return group_id
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Fehler beim API-Request für group_id: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
# Discovery-Phase: Sende Discovery bis ACK empfangen
|
# Discovery-Phase: Sende Discovery bis ACK empfangen
|
||||||
while not discovered:
|
while not discovered:
|
||||||
send_discovery(client, client_id, hardware_token, ip_addr)
|
send_discovery(client, client_id, hardware_token, ip_addr)
|
||||||
client.loop(timeout=1.0)
|
client.loop(timeout=1.0)
|
||||||
time.sleep(HEARTBEAT_INTERVAL)
|
time.sleep(HEARTBEAT_INTERVAL)
|
||||||
|
|
||||||
# Heartbeat-Phase
|
# Event-Topic abonnieren (und bei Änderung wechseln)
|
||||||
|
current_group_id = None
|
||||||
|
event_topic = None
|
||||||
while True:
|
while True:
|
||||||
|
group_id = get_group_id(client_id)
|
||||||
|
logging.debug(
|
||||||
|
f"Aktuelle group_id: {group_id}, vorherige group_id: {current_group_id}")
|
||||||
|
if group_id != current_group_id:
|
||||||
|
# Topic wechseln
|
||||||
|
if event_topic:
|
||||||
|
client.unsubscribe(event_topic)
|
||||||
|
logging.info(f"Event-Topic abbestellt: {event_topic}")
|
||||||
|
event_topic = f"infoscreen/events/{group_id}"
|
||||||
|
client.subscribe(event_topic)
|
||||||
|
current_group_id = group_id
|
||||||
|
logging.info(
|
||||||
|
f"Abonniere Event-Topic: {event_topic} für group_id: {group_id}")
|
||||||
client.publish(f"infoscreen/{client_id}/heartbeat", "alive")
|
client.publish(f"infoscreen/{client_id}/heartbeat", "alive")
|
||||||
logging.debug("Heartbeat gesendet.")
|
logging.debug("Heartbeat gesendet.")
|
||||||
client.loop(timeout=1.0)
|
client.loop(timeout=1.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user