second commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib import admin
|
||||
from django_celery_beat.admin import PeriodicTaskAdmin, PeriodicTask
|
||||
|
||||
from .models import (Device, DeviceType)
|
||||
|
||||
class DeviceAdmin(admin.ModelAdmin):
|
||||
list_display = ('label', 'uuid', 'image_tag', 'device_type__code', 'index', 'task', 'lifetime', 'task_d', 'active')
|
||||
list_filter = ('device_type', 'active')
|
||||
search_fields = ('label',)
|
||||
readonly_fields = ('uuid',)
|
||||
|
||||
def task_e(self, instance):
|
||||
if instance.task:
|
||||
return instance.task.enabled
|
||||
task_e.short_description = _('Activé')
|
||||
|
||||
def task_d(self, instance):
|
||||
if instance.task:
|
||||
return instance.task.description
|
||||
task_d.short_description = _('Description')
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
class DeviceTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'code',)
|
||||
search_fields = ('name', 'code')
|
||||
|
||||
|
||||
class CustomPeriodicTaskAdmin(PeriodicTaskAdmin):
|
||||
list_display = ('name', 'task', 'enabled', 'description', 'scheduler', 'interval',
|
||||
'start_time', 'last_run_at', 'expires', 'one_off', 'total_run_count')
|
||||
search_fields = ('name', 'task')
|
||||
|
||||
|
||||
admin.site.unregister(PeriodicTask)
|
||||
admin.site.register(PeriodicTask, CustomPeriodicTaskAdmin)
|
||||
admin.site.register(DeviceType, DeviceTypeAdmin)
|
||||
admin.site.register(Device, DeviceAdmin)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RelayConfig(AppConfig):
|
||||
name = 'relay'
|
||||
@@ -0,0 +1,43 @@
|
||||
#
|
||||
#from django.utils.translation import gettext_lazy as _
|
||||
#from channels.layers import get_channel_layer
|
||||
#from django.conf import settings
|
||||
#import asyncio
|
||||
#from asgiref.sync import sync_to_async
|
||||
import json, logging
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
#from modules import utils
|
||||
|
||||
#from .models import Relay
|
||||
from .process import redisDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RelayConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
async def connect(self):
|
||||
self.uuid = self.scope["url_route"]["kwargs"].get("uuid")
|
||||
self.this_group = f"relay_{self.uuid}"
|
||||
await self.channel_layer.group_add(self.this_group, self.channel_name)
|
||||
await self.accept()
|
||||
#logger.info(f"==== relay {self.uuid} is connected to {self.this_group} group")
|
||||
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_discard(self.this_group, self.channel_name)
|
||||
logger.info( f"Disconnect from {self.this_group}")
|
||||
|
||||
|
||||
# routage channels => type: relay.message
|
||||
async def relay_message(self, event):
|
||||
await self.send(text_data=json.dumps(event["text"]))
|
||||
|
||||
|
||||
## Receive message from WebSocket
|
||||
async def receive(self, text_data):
|
||||
data = json.loads(text_data)
|
||||
msg_type = data.get("type")
|
||||
if msg_type == "relay":
|
||||
redisDB.publish(f"{msg_type}_{self.uuid}", json.dumps(data))
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_resized import ResizedImageField
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django.dispatch import receiver
|
||||
from django.utils.text import slugify
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models.signals import post_save
|
||||
from django.db import models
|
||||
from modules import utils
|
||||
|
||||
|
||||
class DeviceType(models.Model):
|
||||
name = models.CharField(_("Nom du capteur"), max_length=64, null=True)
|
||||
code = models.SlugField(_("Type de capteur"), help_text=_("Max 16 caractères"), unique=True, max_length=16, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _("Type de capteur")
|
||||
verbose_name_plural = _("Types de capteurs")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.code = slugify(self.code)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
label = models.CharField(_("Capteur"), max_length=100, null=True)
|
||||
uuid = models.SlugField(_("Uuid"), help_text=_('Uuid iot clef unique'), unique=True, max_length=32, null=True, blank=True)
|
||||
device_type = models.ForeignKey(DeviceType, verbose_name=_("Type de capteur"), on_delete=models.CASCADE, null=True, blank=True, )
|
||||
index = models.SmallIntegerField(_("Index"), help_text=_("Numéro d'index"), default=0)
|
||||
pin_active = models.BooleanField(_("Broche"), help_text=_("Broche active à l'initialisation"), default=True)
|
||||
image = ResizedImageField(_("Image"), help_text=_("Image du capteur"), size=[320, 240], upload_to='devices/', null=True, blank=True, default = 'devices/vanne.png')
|
||||
|
||||
task = models.ForeignKey(
|
||||
PeriodicTask,
|
||||
verbose_name=_("Tâche périodique"),
|
||||
help_text=_("Tâche de démarrage périodique de ce capteur (optionnel)"),
|
||||
on_delete=models.CASCADE, null=True, blank=False
|
||||
)
|
||||
lifetime = models.DurationField(_("Durée"), help_text=_("Durée de l'évènement. Jours hh:mm:ss format"), blank=True, null=True, default=timedelta(hours=1, minutes=15))
|
||||
heartbeat = models.IntegerField (_("Battement"), help_text=_("Interrogation du capteur en secondes"), default=1)
|
||||
active = models.BooleanField(_("Actif"), default=True)
|
||||
|
||||
def image_tag(self):
|
||||
return mark_safe(f'<img src="{self.image.url}" style="width: 45px; height:45px;">')
|
||||
image_tag.short_description = _("Image")
|
||||
image_tag.allow_tags = True
|
||||
|
||||
|
||||
def to_register(self):
|
||||
return dict(
|
||||
dev_type=self.device_type.code if self.device_type else 'None',
|
||||
index=self.index,
|
||||
key=self.uuid,
|
||||
label=self.label,
|
||||
status=0,
|
||||
active=1 if self.pin_active else 0,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['index', ]
|
||||
verbose_name = _("Capteur")
|
||||
verbose_name_plural = _("Capteurs")
|
||||
|
||||
@classmethod
|
||||
def all_devices(cls):
|
||||
return Device.objects.filter(active=True).all().order_by('index')
|
||||
|
||||
@classmethod
|
||||
def all_devices_json(cls):
|
||||
devices = Device.objects.filter(active=True).all().order_by('index')
|
||||
buf = []
|
||||
for dev in devices:
|
||||
buf.append({
|
||||
"uuid": dev.uuid,
|
||||
"index": dev.index,
|
||||
})
|
||||
return json.dumps(buf)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.uuid:
|
||||
self.uuid = utils.get_device_uuid(12)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.label}'
|
||||
|
||||
|
||||
@receiver(post_save, sender=Device)
|
||||
def create_device(sender, instance, created, **kwargs):
|
||||
name = f'{instance.uuid}'
|
||||
|
||||
t = PeriodicTask.objects.filter(name__exact=name).first()
|
||||
if not t:
|
||||
p = PeriodicTask.objects.create(
|
||||
name=name,
|
||||
task='relay status',
|
||||
description=_('Pas encore programmé'),
|
||||
interval_id=1,
|
||||
args=json.dumps([instance.uuid])
|
||||
)
|
||||
instance.task_id = p.id
|
||||
instance.save()
|
||||
else:
|
||||
PeriodicTask.objects.filter(id=instance.task.id).update(
|
||||
name=name,
|
||||
args=json.dumps([instance.uuid])
|
||||
)
|
||||
instance.task.save()
|
||||
@@ -0,0 +1,169 @@
|
||||
'''
|
||||
Created on 23 janv. 2026
|
||||
|
||||
@author: denis
|
||||
'''
|
||||
# process.py
|
||||
#from django.utils.translation import gettext_lazy as _
|
||||
import os
|
||||
os.environ['BOARD_DEVICE']="RPI"
|
||||
import json
|
||||
from threading import Thread, Event
|
||||
from asgiref.sync import async_to_sync #, sync_to_async
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from celery import Task
|
||||
from celery.exceptions import Ignore
|
||||
from celery.utils.log import get_task_logger
|
||||
from redis import Redis
|
||||
|
||||
from modules import gpiolib as gpio, devices as dev
|
||||
from . import models
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
|
||||
|
||||
class RelayManager:
|
||||
|
||||
def __init__(self):
|
||||
self.shift_out_register = gpio.ShiftOutRegister74HC595(
|
||||
latch_pin=settings.CTL_LATCH_PIN,
|
||||
clock_pin=settings.CTL_CLOCK_PIN,
|
||||
data_pin=settings.CTL_DATA_PIN,
|
||||
number=settings.CTL_NUMBER,
|
||||
)
|
||||
|
||||
def create_relay(self, device, publish=None):
|
||||
return dev.Relay(self.shift_out_register, device.to_register(), publish=publish)
|
||||
|
||||
|
||||
relay_manager = RelayManager()
|
||||
|
||||
|
||||
class RelayProcess(Task):
|
||||
|
||||
def __init__(self, uuid):
|
||||
super().__init__()
|
||||
self.channel_layer = get_channel_layer()
|
||||
self.uuid = uuid
|
||||
self.group = f'relay_{uuid}'
|
||||
self.stop_event = Event()
|
||||
self.heartbeat = 10
|
||||
self.relay = None
|
||||
self.device = None
|
||||
|
||||
def __call__(self, uuid, *args, **kwargs):
|
||||
self.uuid = uuid
|
||||
return self.start(*args, **kwargs)
|
||||
|
||||
|
||||
def redis_pub(self, state, **msg):
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.group, {
|
||||
"type": 'relay.message',
|
||||
"text": { 'topic': state, **msg }
|
||||
}
|
||||
)
|
||||
self.mqtt_pub(state, **msg)
|
||||
|
||||
|
||||
def mqtt_pub(self, state, **payload):
|
||||
topic = f"relay/{self.uuid}/{state}"
|
||||
payload.pop("uuid", None)
|
||||
redisDB.publish('mqtt_pub', json.dumps({"type": "mqtt", "topic": topic, "payload": payload}))
|
||||
#logger.info(f"mqtt_pub {topic}: {payload}")
|
||||
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.device = models.Device.objects.select_for_update().get(uuid=self.uuid)
|
||||
self.stop_event.clear()
|
||||
self.start_services()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Relay error {self.uuid}: {e}")
|
||||
raise Ignore()
|
||||
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.redis_pub('state', stop="done")
|
||||
self.stop_event.set()
|
||||
logger.info(f"Relay {self.uuid} stopped.")
|
||||
Event().wait(1.0)
|
||||
finally:
|
||||
self.stop_event.set()
|
||||
gpio.IO_CLEANUP()
|
||||
|
||||
|
||||
def start_services(self):
|
||||
Thread(target=self._listen_to_redis, daemon=True).start()
|
||||
Thread(target=self._relay_process, daemon=True).start()
|
||||
|
||||
|
||||
def _listen_to_redis(self):
|
||||
try:
|
||||
logger.info(f"RelayProcess {self.group}: listen via redisDB")
|
||||
pubsub = redisDB.pubsub()
|
||||
pubsub.subscribe(f"{self.group}", "relay_broadcast", "mqtt_message")
|
||||
try:
|
||||
for message in pubsub.listen():
|
||||
#logger.info(f"{message}")
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
|
||||
cmd = json.loads(str(message.get('data')))
|
||||
if not isinstance(cmd, dict):
|
||||
continue
|
||||
|
||||
if cmd["type"]=="relay" or cmd["type"]=="mqtt":
|
||||
topic = cmd["topic"]
|
||||
|
||||
if topic == "start":
|
||||
duration = cmd.get("value", 0)
|
||||
self.relay.relay_start(duration=duration)
|
||||
|
||||
elif topic == "stop":
|
||||
self.relay.relay_stop()
|
||||
|
||||
elif topic in ["status", "init"]:
|
||||
self.relay.relay_status()
|
||||
|
||||
elif topic == "halt":
|
||||
self.relay.relay_halt()
|
||||
|
||||
elif topic == "restart":
|
||||
self.relay.relay_restart()
|
||||
except Exception as e:
|
||||
logger.error(f'listen_to_redis: {e}')
|
||||
finally:
|
||||
pubsub.unsubscribe()
|
||||
pubsub.close()
|
||||
|
||||
|
||||
def _relay_process(self):
|
||||
try:
|
||||
logger.info(f"RelayProcess Start {self.uuid}")
|
||||
self.relay = relay_manager.create_relay(self.device, publish=self.redis_pub)
|
||||
self.relay.relay_stop()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
Event().wait(self.device.heartbeat)
|
||||
self.relay.relay_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'====> {e}')
|
||||
Event().wait(1.0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Relay {self.uuid}: {e}")
|
||||
finally:
|
||||
|
||||
logger.info(f"End of relay process: {self.uuid}")
|
||||
self.redis_pub('state', relay="stop")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#
|
||||
# routing.py
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"ws/relay/(?P<uuid>[^/]+)/?$", consumers.RelayConsumer.as_asgi()),
|
||||
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
#relay-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns, 3), 1fr);
|
||||
gap: 0.51em;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.relay-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.relay-meta {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
padding: .5em;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
|
||||
|
||||
class RelayOverlay {
|
||||
|
||||
constructor(parent, relay) {
|
||||
this.parent = parent;
|
||||
this.relay = relay;
|
||||
this.uuid = relay.uuid;
|
||||
this.sockets = parent.sockets;
|
||||
|
||||
this.init_controls();
|
||||
}
|
||||
|
||||
init_controls() {
|
||||
this.duration_box = sId('duration-'+this.uuid);
|
||||
this.start_box = sId('start-'+this.uuid);
|
||||
this.stop_box = sId('stop-'+this.uuid);
|
||||
this.flow_box = sId('flow-'+this.uuid);
|
||||
this.ts_box = sId('ts-'+this.uuid);
|
||||
this.rate_box = sId('rate-'+this.uuid);
|
||||
this.remaining_box = sId('remaining-'+this.uuid);
|
||||
this.duration_value = parseInt(this.duration_box.value);
|
||||
this.relay_state = 0;
|
||||
this.flow = 0.00;
|
||||
|
||||
this.start_box.onclick = () => {
|
||||
this.start();
|
||||
};
|
||||
this.stop_box.onclick = () => {
|
||||
this.stop();
|
||||
};
|
||||
this.duration_box.onchange = () => {
|
||||
this.duration_value = parseInt(this.duration_box.value);
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
if (payload.topic === "status") {
|
||||
this.relay_state = payload.relay_state;
|
||||
this.flow = parseFloat(payload.flow.toFixed(2));
|
||||
this.rate = parseFloat(payload.rate.toFixed(2));
|
||||
|
||||
this.start_box.disabled = this.relay_state === 1;
|
||||
this.stop_box.disabled = this.relay_state === 0;
|
||||
|
||||
if (payload.remaining>0) {
|
||||
this.remaining_box.textContent = `${payload.remaining} s restante(s)`;
|
||||
} else {
|
||||
this.remaining_box.textContent = "";
|
||||
}
|
||||
this.ts_box.textContent = new Date(payload.ts * 1000).toLocaleTimeString();
|
||||
this.duration_box.value = this.duration_value;
|
||||
this.flow_box.textContent = this.flow;
|
||||
this.rate_box.textContent = this.rate;
|
||||
}
|
||||
}
|
||||
|
||||
start() { this._send({ type: 'relay', topic: "start", value: this.duration_value }); }
|
||||
stop() { this._send({ type: 'relay', topic: "stop" }); }
|
||||
status() { this._send({ type: 'relay', topic: "status" }); }
|
||||
halt() { this._send({ type: 'relay', topic: "halt" }); }
|
||||
restart() { this._send({ type: 'relay', topic: "restart" }); }
|
||||
_send(message) { this.sockets[this.uuid].send(message); }
|
||||
}
|
||||
|
||||
class RelayManager {
|
||||
constructor(container, relay) {
|
||||
this.container = container;
|
||||
this.relay = relay;
|
||||
this.overlays = {};
|
||||
this.sockets = {};
|
||||
this.init_controls();
|
||||
}
|
||||
|
||||
init_controls() {
|
||||
this.all_relay_state = 0;
|
||||
this.startall_box = sId('start-all');
|
||||
this.stopall_box = sId('stop-all');
|
||||
this.flow_box = sId('flow-all');
|
||||
|
||||
this.startall_box.onclick = () => {
|
||||
this.all_relay_state = 1;
|
||||
this._broadcast({ type: 'relay-multi', action: "start" });
|
||||
};
|
||||
this.stopall_box.onclick = () => {
|
||||
this.all_relay_state = 0;
|
||||
this._broadcast({ type: 'relay-multi', action: "stop" });
|
||||
};
|
||||
this.update_button();
|
||||
}
|
||||
|
||||
async start() {
|
||||
for (const relay of this.relay) {
|
||||
const overlay = new RelayOverlay(this, relay);
|
||||
this.overlays[relay.uuid] = overlay;
|
||||
}
|
||||
}
|
||||
|
||||
registerSocket(uuid, socket) {
|
||||
this.sockets[uuid] = socket;
|
||||
}
|
||||
|
||||
update_button() {
|
||||
this.startall_box.disabled = this.all_relay_state === 1;
|
||||
this.stopall_box.disabled = this.all_relay_state === 0;
|
||||
}
|
||||
|
||||
flow_update() {
|
||||
let total_flow = 0.00;
|
||||
for (const relay of this.relay) {
|
||||
const overlay = this.overlays[relay.uuid];
|
||||
if (overlay) {
|
||||
total_flow = total_flow + overlay.flow;
|
||||
}
|
||||
}
|
||||
this.flow_box.textContent = total_flow.toFixed(2);
|
||||
}
|
||||
|
||||
update(uuid, payload) {
|
||||
const overlay = this.overlays[uuid];
|
||||
if (overlay) {
|
||||
overlay.update(payload);
|
||||
}
|
||||
}
|
||||
|
||||
_broadcast(message) {
|
||||
if (message.type === 'relay-multi') {
|
||||
const action = message.action;
|
||||
this.update_button();
|
||||
for (const relay of this.relay) {
|
||||
const overlay = this.overlays[relay.uuid];
|
||||
overlay[action]();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MetadataSocket {
|
||||
constructor(url, uuid) {
|
||||
this.url = url;
|
||||
this.uuid = uuid;
|
||||
this.ws = null;
|
||||
this.manager = null;
|
||||
this.reconnectDelay = 1000;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnect = false;
|
||||
}
|
||||
|
||||
setOverlayManager(manager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
this.manager.update(this.uuid, data);
|
||||
this.manager.flow_update();
|
||||
};
|
||||
|
||||
this.ws.onopen = (event) => {
|
||||
const overlay = this.manager.overlays[this.uuid];
|
||||
if (overlay && !this.reconnect)
|
||||
overlay['status']();
|
||||
this.reconnect = false;
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.warn(`WebSocket closed ${this.uuid}`);
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnect = true;
|
||||
setTimeout(() => {
|
||||
console.log("Reconnect WebSocket...");
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
send(obj) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
const cpu_used = sId('cpu-used');
|
||||
const shm_used = sId('shm-used');
|
||||
const mem_used = sId('mem-used');
|
||||
const disk_used = sId('disk-used');
|
||||
const ramdisk_used = sId('ramdisk-used');
|
||||
|
||||
let autoTimer = null;
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const r = await fetch(stats_endpoint, { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const j = await r.json();
|
||||
//console.log(j);
|
||||
const cpu_percent = j.cpu_info.cpu_percent+'%'; cpu_used.style.setProperty("--cpu-used", cpu_percent); cpu_used.title=`Cpu: ${cpu_percent}`;
|
||||
const shm_length = j.shm.length; shm_used.style.setProperty("--shm_used", shm_length); shm_used.title= `Shm: ${shm_length}`;
|
||||
const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`;
|
||||
const root_percent = j.disk_info.root.percent+'%'; disk_used.style.setProperty("--disk-used", root_percent); disk_used.title=`Disk: ${root_percent}`;
|
||||
let ramdisk_percent = "0%";
|
||||
if (! j.ramdisk_info) ramdisk_percent = j.ramdisk_info.percent+'%';
|
||||
ramdisk_used.style.setProperty("--ramdisk-used", ramdisk_percent); ramdisk_used.title=`Ramdisk: ${ramdisk_percent}`;
|
||||
|
||||
} catch (e) {
|
||||
console.log('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
function auto_fetch_start() { fetchStats(); autoTimer = setInterval(fetchStats, 5000);}
|
||||
function auto_fetch_stop() { clearInterval(autoTimer); autoTimer = null; }
|
||||
|
||||
auto_fetch_start();
|
||||
@@ -0,0 +1,64 @@
|
||||
# tasks.py
|
||||
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from .process import RelayProcess
|
||||
from .models import Device
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
class RelayTaskManager:
|
||||
|
||||
def __init__(self):
|
||||
self.active_relay = {}
|
||||
|
||||
def start_relay(self, uuid):
|
||||
if uuid not in self.active_relay:
|
||||
relay = RelayProcess(uuid)
|
||||
relay.start()
|
||||
self.active_relay[uuid] = relay
|
||||
else:
|
||||
logger.info(f"Relay {uuid} already running!...")
|
||||
|
||||
def stop_relay(self, uuid):
|
||||
if uuid in self.active_relay:
|
||||
self.active_relay[uuid].stop()
|
||||
del self.active_relay[uuid]
|
||||
|
||||
def stop_all_relay(self):
|
||||
for _, relay in self.active_relay.items():
|
||||
relay.stop()
|
||||
self.active_relay.clear()
|
||||
|
||||
def start_all_relay(self):
|
||||
all_relays = Device.all_devices()
|
||||
for relay in all_relays:
|
||||
self.start_relay(relay.uuid)
|
||||
|
||||
|
||||
task_manager = RelayTaskManager()
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def relay_start(self, uuid):
|
||||
task_manager.start_relay(uuid)
|
||||
return f"Relai {uuid} démarré."
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def relay_stop(self, uuid):
|
||||
task_manager.stop_relay(uuid)
|
||||
return f"Relai {uuid} arrêté."
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def relay_stop_all(self):
|
||||
task_manager.stop_all_relay()
|
||||
return "Touts les Relais arrêtées."
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def relay_start_all(self):
|
||||
task_manager.start_all_relay()
|
||||
return "Touts les Relais en cours de démarrage."
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n home_tags %}
|
||||
{% block header %}
|
||||
<div class="w3-bar w3-dark-light">
|
||||
<a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-xlarge w3-text-yellow" onclick="toggleSidebar()" title="{% trans 'Barre de côté' %}">
|
||||
<i class="fa-solid fa-right-left"></i>
|
||||
</a>
|
||||
<a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-right w3-xlarge w3-text-green" onclick="goFullscreen()" title="{% trans 'Ecran plein' %}">
|
||||
<i class="fa-solid fa-maximize"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block sidebar %}
|
||||
<div class="w3-dark-xlight w3-border-bottom">
|
||||
<div class="w3-large w3-bold"><a href="/" class="w3-btn w3-hover-opacity w100 w3-left-align">{{ app_title }}</a></div>
|
||||
<div class="w3-padding">
|
||||
<span class="w3-padding">{{ request.user.username }}</span>
|
||||
<form method="post" action="{% url 'logout' %}" class="w3-right">
|
||||
{% csrf_token %}
|
||||
<button class="w3-bar-item w3-btn w3-hover-opacity" type="submit" title="{% trans 'Déconnexion' %}">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% block system %}
|
||||
<div class="w3-light-blue w3-padding-large">
|
||||
<div id="shm-used" class="w3-green w3-round" style="height:0.5em; width: var(--shm-used, 0%);" title="{% trans 'Mémoire partagée' %}"></div>
|
||||
<div id="ramdisk-used" class="w3-purple w3-round" style="height:0.5em;width: var(--ramdisk-used, 0%); margin: 0.35em 0" title="{% trans 'Disque RAM' %}"></div>
|
||||
<div id="mem-used" class="w3-deep-orange w3-round" style="height:0.5em;width: var(--mem-used, 0%); margin: 0.35em 0" title="{% trans 'Mémoire RAM' %}"></div>
|
||||
<div id="cpu-used" class="w3-indigo w3-round" style="height:0.5em;width: var(--cpu-used, 0%); margin: 0.35em 0" title="{% trans 'Charge CPU' %}"></div>
|
||||
<div id="disk-used" class="w3-amber w3-round" style="height:0.5em;width: var(--disk-used, 0%); margin: 0.35em 0" title="{% trans 'Disque /' %}"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block columns %}
|
||||
<div class="w3-dark-light">
|
||||
<div class=" w3-padding w3-border-bottom">
|
||||
<span>{% trans "Grille" %}</span><br>
|
||||
<span>
|
||||
{% x_range 2 6 as cols %}
|
||||
{% for c in cols %}
|
||||
<button id="swap-{{ c }}" class="w3-btn w3-badge w3-padding-small w3-sand w3-tiny" onclick="setGridColumns({{ c }})">{{ c }}</button>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function updateActiveButton(n) {
|
||||
document.querySelectorAll('[id^="swap-"]').forEach(btn => {
|
||||
btn.classList.remove('w3-blue', 'w3-sand'); btn.classList.add('w3-sand');
|
||||
});
|
||||
const active = sId('swap-'+ n);
|
||||
if (active) { active.classList.remove('w3-sand'); active.classList.add('w3-blue');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block sidebar_list %}
|
||||
<div class="w3-dark-light w3-padding-small">
|
||||
<a href="{% url 'relay:dashboard' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||
<i class="fa-solid fa-table-cells w3-text-green"></i> {% trans "Tableau de bord" %}
|
||||
</a>
|
||||
{% block sidebar_command %}{% endblock %}
|
||||
|
||||
<a href="{% url 'relay:admin' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-top-6">
|
||||
<i class="fa-solid fa-gear w3-text-pink"></i> {% trans "Administration base de données" %}
|
||||
</a>
|
||||
<a href="{% url 'relay:supervisor' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||
<i class="fa-solid fa-gears w3-text-purple"></i> {% trans "Supervisor" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% block js_footer %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
const stats_endpoint = "{% url 'relay:api_stats' %}";
|
||||
</script>
|
||||
<script src="/static/relay/js/stats.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends 'relay/base.html' %}
|
||||
{% load i18n home_tags %}
|
||||
|
||||
{% block styles %}
|
||||
{{ block.super }}
|
||||
<link href="/static/relay/css/relay.css" rel="stylesheet">
|
||||
{% endblock %}
|
||||
{% block sidebar_command %}
|
||||
<div class="w3-row w3-border w3-margin-top">
|
||||
<div class="w3-third w3-center">
|
||||
<button id="start-all" class="w3-btn w3-blue w3-round w3-margin-2 w3-hover-opacity w3-block" title="{% trans "Tout démarrer" %}">
|
||||
{% trans "Marche" %}
|
||||
</button>
|
||||
<button id="stop-all" class="w3-btn w3-pink w3-round w3-margin-2 w3-hover-opacity w3-block w3-margin-top" title="{% trans "Tout arrêter" %}">
|
||||
{% trans "Arrêt" %}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="w3-twothird">
|
||||
<div class="w3-center">{% trans "Débit total" %}</div>
|
||||
<div class="w3-dark-xlight w3-border w3-padding w3-margin w3-right-align">
|
||||
<div id="flow-all" class="w3-wide w3-bold">0.00</div><div>litres</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div id="relay-grid" class="w3-black">
|
||||
{% for dev in devices %}
|
||||
<div id="box-{{ dev.uuid }}" class="relay-box w3-border w3-round w3-round-large">
|
||||
<div class="w3-row-padding">
|
||||
<div class="w3-col s4 w3-center">
|
||||
<div class="w3-padding-small">{{ dev.label }}</div>
|
||||
<img src="{{ dev.image.url }}" class="w3-circle" style="width:64px;">
|
||||
<select id="duration-{{ dev.uuid }}">
|
||||
<option value="0" selected>{% trans 'Sans délai' %}</option>
|
||||
<option value="30">30 s</option>
|
||||
<option value="60">1 mn</option>
|
||||
<option value="120">2 mn</option>
|
||||
<option value="300">5 mn</option>
|
||||
<option value="600">10 mn</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w3-col s3">
|
||||
<div id="ts-{{ dev.uuid }}" class="w3-small w3-padding-small"></div>
|
||||
<button id="start-{{ dev.uuid }}" class="w3-btn w3-blue w3-round w3-margin-2 w3-hover-opacity w3-block w3-padding-small">
|
||||
{% trans "Marche" %}
|
||||
</button>
|
||||
<button id="stop-{{ dev.uuid }}" class="w3-btn w3-pink w3-round w3-margin-2 w3-hover-opacity w3-block w3-padding-small">
|
||||
{% trans "Arrêt" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w3-col s5">
|
||||
<div class="w3-padding-small">{% trans "Débit" %}: <span id="rate-{{ dev.uuid }}" class="w3-small"></span> l/mn</div>
|
||||
<div class="w3-dark-xlight w3-border w3-padding w3-right-align w100">
|
||||
<div id="flow-{{ dev.uuid }}" class="w3-wide w3-bold"></div><div>litres</div>
|
||||
</div>
|
||||
<div id="remaining-{{ dev.uuid }}" class="w3-small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_footer %}
|
||||
{{ block.super }}
|
||||
<script src="/static/relay/js/relay.js"></script>
|
||||
<script>
|
||||
const container = sId("relay-grid");
|
||||
const ws_route = "{{ ws_route }}";
|
||||
const columns = "{{ columns }}";
|
||||
const devices = {{ relay|safe }};
|
||||
|
||||
function setGridColumns(col) {
|
||||
container.style.setProperty("--grid-columns", col);
|
||||
updateActiveButton(col);
|
||||
}
|
||||
|
||||
// ---- Point d'entrée ----
|
||||
(async () => {
|
||||
const manager = new RelayManager(container, devices);
|
||||
await manager.start();
|
||||
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
for (const uuid in manager.overlays) {
|
||||
const wsUrl = `${protocol}://${location.host}/${ws_route}/${uuid}`;
|
||||
|
||||
const socket = new MetadataSocket(wsUrl, uuid);
|
||||
socket.setOverlayManager(manager);
|
||||
socket.connect();
|
||||
manager.registerSocket(uuid, socket);
|
||||
}
|
||||
setGridColumns(columns);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{% extends 'relay/base.html' %}
|
||||
{% block columns %}{% endblock %}
|
||||
{% block content %}
|
||||
<iframe width="100%" src="{{ link }}" style="border:none; height:90vh;display:block;"></iframe>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "relay"
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
path('dashboard/', views.dashboard_view, name='dashboard'),
|
||||
path('relay/admin/', views.admin_view, name='admin'),
|
||||
path('relay/supervisor/', views.supervisor_view, name='supervisor'),
|
||||
path('api/stats/', views.stats_view, name='api_stats'),
|
||||
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
from django.shortcuts import render
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.conf import settings
|
||||
from modules.system_stats import get_cached_stats, start_background_updater
|
||||
|
||||
from . import models
|
||||
|
||||
start_background_updater()
|
||||
|
||||
|
||||
@require_GET
|
||||
def stats_view(request):
|
||||
"""
|
||||
Retourne tout le cache (shm, cpu_info, memory_info, disk_info, updated_at)
|
||||
"""
|
||||
try:
|
||||
data = get_cached_stats()
|
||||
return JsonResponse(data, safe=False)
|
||||
except Exception as e:
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def global_context(request, **ctx):
|
||||
return dict(
|
||||
app_title=settings.APP_TITLE,
|
||||
app_sub_title=settings.APP_SUB_TITLE,
|
||||
**ctx
|
||||
)
|
||||
|
||||
@login_required
|
||||
def dashboard_view(request):
|
||||
ctx = dict(
|
||||
devices=models.Device.all_devices(),
|
||||
relay=models.Device.all_devices_json(),
|
||||
ws_route=settings.RELAY_WEBSOCKET_ROUTE,
|
||||
columns=3,
|
||||
|
||||
)
|
||||
return render(request, "relay/dashboard.html", context=global_context(request, **ctx))
|
||||
|
||||
@login_required
|
||||
def admin_view(request):
|
||||
return render(request, "relay/iframe.html", context=global_context(request, link='/admin/'))
|
||||
|
||||
@login_required
|
||||
def supervisor_view(request):
|
||||
return render(request, "relay/iframe.html", context=global_context(request, link='http://relay.local:9001/'))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user