ogclient: add kiosk

Add Kiosk project as a subtree of ogClient. Kiosk is
an interactive GUI featuring basic functionality to
monitor and operate ogClient withing the client through a
graphical interface written in PyQt6.

Right after ogClient launches in live mode it performs a
fork() call to launch Kiosk as an external process to
prevent Kiosk backtraces to affect ogClient.

A pair of sockets are created through socket.socketpair()
and each process is assigned one of them to leverage the
inter process communication.

API Kiosk -> ogClient:
- Poweroff: request client poweroff
{"command": "poweroff"}
- Reboot: request client reboot
{"command": "reboot"}
- Restore: restore image into a partition
{"command": "restore", "image": "windows.img", "disk": 1, "partition": 2}
- Boot: request an OS boot
{"command": "boot", "disk": 1, "partition": 2}

API ogClient -> Kiosk:
- Busy: inform about ogClient thread status
{"command": "busy", "status": True}
- Refresh: reload the theme.
{"command": "refresh"}
- close: request Kiosk termination.
{"command": "close"}

Add internationalization documentation in README

Add "CACHE" mode in image restore to only restore images
available in the cache partition.

Use set_state() function in OgRest to define the idle or busy
status and notify Kiosk about the status change.
ogkiosk
Alejandro Sirgo Rica 2025-01-17 13:35:27 +01:00
parent 109e712fb5
commit 7ace7f9403
35 changed files with 1797 additions and 33 deletions

3
.gitignore vendored
View File

@ -130,3 +130,6 @@ dmypy.json
# ignore swp
*.swp
# Qt
*.qm

34
README 100644
View File

@ -0,0 +1,34 @@
# Kiosk
Interactive kiosk software for ogClient.
## Translation
Qt uses .ts files to store the translations for each language. These files are
generated from strings surounded by the tr() function in the code.
The program needs a binary file with the translation information, this file
has a qm extension and it is generated by the tool lrelease-qt6.
Dependencies:
Generating the .ts files requires pylupdate6 provided by
pip install pyqt6
For the .qm file generation install the package qt6-tools package from your
distribution's repository.
Update .ts files:
run update_translations.sh in the misc folder.
Generate .qm files:
run compile_translations.sh in the misc folder.
Edit translations:
The .ts files can be manually edited as they are XML files.
Optinally the software linguist-qt6 can be used to edit the files through a
graphical interface. This program is also provided by the package qt6-tools.
Steps to translate with Linguist:
- Launch linguist-qt6
- Open the .ts file from the translations directory.
- Translate the strings as needed.
- Save the translations.

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
translations_dir="$script_dir/../src/kiosk/translations"
# Compile each .ts file in the directory
for ts_file in "$translations_dir"/*.ts; do
lrelease-qt6 "$ts_file"
done
echo "All translations have been compiled"

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M160 80c0-26.5 21.5-48 48-48l32 0c26.5 0 48 21.5 48 48l0 352c0 26.5-21.5 48-48 48l-32 0c-26.5 0-48-21.5-48-48l0-352zM0 272c0-26.5 21.5-48 48-48l32 0c26.5 0 48 21.5 48 48l0 160c0 26.5-21.5 48-48 48l-32 0c-26.5 0-48-21.5-48-48L0 272zM368 96l32 0c26.5 0 48 21.5 48 48l0 288c0 26.5-21.5 48-48 48l-32 0c-26.5 0-48-21.5-48-48l0-288c0-26.5 21.5-48 48-48z"/></svg>

After

Width:  |  Height:  |  Size: 638 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 32a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm-96-32a96 96 0 1 0 192 0 96 96 0 1 0 -192 0zM96 240c0-35 17.5-71.1 45.2-98.8S205 96 240 96c8.8 0 16-7.2 16-16s-7.2-16-16-16c-45.4 0-89.2 22.3-121.5 54.5S64 194.6 64 240c0 8.8 7.2 16 16 16s16-7.2 16-16z"/></svg>

After

Width:  |  Height:  |  Size: 582 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M64 0C28.7 0 0 28.7 0 64L0 352c0 35.3 28.7 64 64 64l176 0-10.7 32L160 448c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-69.3 0L336 416l176 0c35.3 0 64-28.7 64-64l0-288c0-35.3-28.7-64-64-64L64 0zM512 64l0 288L64 352 64 64l448 0z"/></svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 224c0 17.7 14.3 32 32 32s32-14.3 32-32l0-224zM143.5 120.6c13.6-11.3 15.4-31.5 4.1-45.1s-31.5-15.4-45.1-4.1C49.7 115.4 16 181.8 16 256c0 132.5 107.5 240 240 240s240-107.5 240-240c0-74.2-33.8-140.6-86.6-184.6c-13.6-11.3-33.8-9.4-45.1 4.1s-9.4 33.8 4.1 45.1c38.9 32.3 63.5 81 63.5 135.4c0 97.2-78.8 176-176 176s-176-78.8-176-176c0-54.4 24.7-103.1 63.5-135.4z"/></svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M463.5 224l8.5 0c13.3 0 24-10.7 24-24l0-128c0-9.7-5.8-18.5-14.8-22.2s-19.3-1.7-26.2 5.2L413.4 96.6c-87.6-86.5-228.7-86.2-315.8 1c-87.5 87.5-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3c62.2-62.2 162.7-62.5 225.3-1L327 183c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8l119.5 0z"/></svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@ -0,0 +1,77 @@
#!/usr/bin/python3
#
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
import os
import sys
import json
import time
import socket
import select
import logging
from src.kiosk.kiosk import launch_kiosk
logging.basicConfig(
level=logging.INFO,
format='(%(asctime)s): [%(levelname)s] - %(message)s', # Optional log format
stream=sys.stdout # Direct the output to stdout
)
def send_event(socket, data):
try:
json_data = json.dumps(data)
logging.info(f'sending to kiosk: {json_data}')
socket.send(json_data.encode('utf-8'))
except TypeError as e:
logging.error(f'Failed to encode data to JSON: {e}')
except Exception as e:
logging.error(f'Unexpected error in send_kiosk_event: {e}')
def test_requests_on_interrupt():
pass
if __name__ == '__main__':
kiosk_sock_pair = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)
client_socket = kiosk_sock_pair[0]
kiosk_socket = kiosk_sock_pair[1]
try:
kiosk_pid = os.fork()
if kiosk_pid == 0:
logging.info('Launching Kiosk...')
try:
ret = launch_kiosk(socket=kiosk_socket, debug_mode=True)
sys.exit(ret)
except Exception as e:
logging.exception(f"Error during Kiosk execution: {e}")
sys.exit(1)
except Exception as e:
logging.error(f'Could not launch Kiosk: {e}')
raise
try:
logging.info('Parent process: Monitoring Kiosk communication...')
while True:
readable, _, _ = select.select([client_socket], [], [])
if client_socket in readable:
data = client_socket.recv(4096)
if data:
print(f'Received from Kiosk: {data.decode('utf-8')}')
else:
print('Kiosk disconnected')
break
except KeyboardInterrupt:
logging.info('Interrupted by user. Executing test requests')
test_requests_on_interrupt()
except Exception as e:
logging.error(f'Error during communication: {e}')
finally:
send_event(client_socket, {'command': 'close'})
client_socket.close()

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
translations_dir="$script_dir/../src/kiosk/translations"
kiosk_dir="$script_dir/../src/kiosk"
# Update all .ts files in the translations directory
for ts_file in "$kiosk_dir"/translations/*.ts; do
if [[ -f $ts_file ]]; then
echo "Updating translations in: $ts_file"
pylupdate6 "$kiosk_dir"/*.py --verbose --no-obsolete -ts "$ts_file"
else
echo "No .ts files found in the translations directory."
exit 1
fi
done
echo "All translations have been updated"

View File

@ -8,6 +8,8 @@
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
import os
import sys
import json
import logging
import argparse
@ -80,9 +82,32 @@ def main():
try:
client = ogClient(config=CONFIG)
client.connect()
client.run()
except OgError as e:
logging.critical(e)
if MODE == 'live' and not shutil.which('browser'):
try:
from src.kiosk.kiosk import launch_kiosk
kiosk_pid = os.fork()
if kiosk_pid == 0:
client.shutdown()
logging.info('Launching Kiosk...')
try:
ret = launch_kiosk(socket=client.get_kiosk_socket_1(), debug_mode=False)
sys.exit(ret)
except Exception as e:
logging.exception(f"Error during Kiosk execution: {e}")
sys.exit(1)
except Exception as e:
logging.error(f"Could not launch Kiosk: {e}")
try:
client.run()
except OgError as e:
logging.critical(e)
finally:
if MODE == 'live' and not shutil.which('browser'):
client.send_kiosk_event({'command': 'close'})
if __name__ == "__main__":
main()

176
src/kiosk/boot.py 100644
View File

@ -0,0 +1,176 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from PyQt6.QtWidgets import (
QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QLabel, QToolButton
)
from PyQt6.QtCore import Qt, QSize, pyqtSignal
from PyQt6.QtGui import QPixmap, QIcon
from src.kiosk.config import *
from src.kiosk.theme import *
class BootView(QWidget):
command_requested = pyqtSignal(dict)
def request_command(self, payload):
self.command_requested.emit(payload)
def __init__(self):
super().__init__()
self.checked_os = None
probe_partitions()
self.layout = QVBoxLayout()
self.layout.setSpacing(10)
self.layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
image_label = QLabel()
pixmap = get_image('header.png')
image_label.setPixmap(pixmap)
image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(image_label)
boot_os_list = get_boot_os_list()
self.restore_button = QPushButton(self.tr(' Restore'))
self.boot_button = QPushButton(self.tr(' Boot'))
self.os_layout = QVBoxLayout()
self.os_layout.setSpacing(2)
self.os_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addLayout(self.os_layout)
os_row_layout = None
win_icon = get_icon('windows.png')
linux_icon = get_icon('linux.png')
for part_idx, part_data in enumerate(boot_os_list):
if not os_row_layout or os_row_layout.count() == get_os_columns():
os_row_layout = QHBoxLayout()
os_row_layout.setSpacing(2)
os_row_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.os_layout.addLayout(os_row_layout)
button = QToolButton()
button.setText(' {os_name} ({os_disk},{os_part})'.format(
os_name=part_data['name'],
os_disk=part_data['disk'],
os_part=part_data['partition']
))
button.setStyleSheet(f'height: {BUTTON_HEIGHT}px; width: {BUTTON_WIDTH}px;')
button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
button.setCheckable(True)
if part_data['os'] == OSFamily.WINDOWS:
button.setIcon(win_icon)
elif part_data['os'] == OSFamily.LINUX:
button.setIcon(linux_icon)
button.setIconSize(QSize(BUTTON_ICON_SIZE, BUTTON_ICON_SIZE))
button.toggled.connect(lambda checked, b=button: self.on_part_button_checked(checked, b))
button.setProperty('part_idx', part_idx)
os_row_layout.addWidget(button)
if not self.checked_os:
button.setChecked(True)
power_layout = QHBoxLayout()
power_layout.setSpacing(10)
power_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addLayout(power_layout)
# Reboot
reboot_button = QPushButton()
reboot_button.setStyleSheet(f'height: {BUTTON_HEIGHT}px; width: {BUTTON_HEIGHT}px;')
icon = get_icon('rotate-right.png')
reboot_button.setIcon(icon)
reboot_button.setIconSize(QSize(POWER_ICON_SIZE, POWER_ICON_SIZE))
power_layout.addWidget(reboot_button)
reboot_button.clicked.connect(self.on_reboot_pressed)
boot_qss = f'height: {BUTTON_HEIGHT - 6}px; width: {BUTTON_HEIGHT * 2}px; font-size: 22px;'
# Restore
icon = get_icon('compact-disc.png')
self.restore_button.setIcon(icon)
self.restore_button.setStyleSheet(boot_qss)
power_layout.addWidget(self.restore_button)
self.restore_button.clicked.connect(self.on_restore_pressed)
self.restore_button.setVisible(len(boot_os_list) > 0)
# Boot
icon = get_icon('display.png')
self.boot_button.setIcon(icon)
self.boot_button.setStyleSheet(boot_qss)
power_layout.addWidget(self.boot_button)
self.boot_button.clicked.connect(self.on_boot_os_pressed)
self.boot_button.setVisible(len(boot_os_list) > 0)
# Poweroff
poweroff_button = QPushButton()
icon = get_icon('power-off.png')
poweroff_button.setIcon(icon)
poweroff_button.setIconSize(QSize(POWER_ICON_SIZE, POWER_ICON_SIZE))
poweroff_button.setStyleSheet(f'height: {BUTTON_HEIGHT}px; width: {BUTTON_HEIGHT}px;')
power_layout.addWidget(poweroff_button)
poweroff_button.clicked.connect(self.on_poweroff_pressed)
# Footer
image_label = QLabel()
pixmap = get_image('footer.png')
image_label.setPixmap(pixmap)
image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(image_label)
self.setLayout(self.layout)
def on_part_button_checked(self, checked, checked_button):
partition_has_image = False
if checked:
if self.checked_os and self.checked_os != checked_button:
self.checked_os.setChecked(False)
self.checked_os = checked_button
part_idx = checked_button.property('part_idx')
if part_idx is not None:
part_data = get_boot_os_list()[part_idx]
partition_has_image = bool(part_data.get('image', ''))
else:
self.checked_os = None
self.boot_button.setEnabled(checked)
self.restore_button.setEnabled(checked and partition_has_image)
def on_reboot_pressed(self):
payload = {'command': 'reboot'}
self.request_command(payload)
def on_poweroff_pressed(self):
payload = {'command': 'poweroff'}
self.request_command(payload)
def on_boot_os_pressed(self):
part_idx = self.checked_os.property('part_idx')
part_data = get_boot_os_list()[part_idx]
payload = {
'command': 'boot',
'disk': part_data['disk'],
'partition': part_data['partition']
}
self.request_command(payload)
def on_restore_pressed(self):
part_idx = self.checked_os.property('part_idx')
part_data = get_boot_os_list()[part_idx]
payload = {
'command': 'restore',
'image': part_data['image'],
'disk': part_data['disk'],
'partition': part_data['partition']
}
self.request_command(payload)

View File

@ -0,0 +1,75 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from src.utils.probe import os_probe, get_os_family, OSFamily
from src.utils.fs import mount_mkdir, umount
from PyQt6.QtGui import QColor
from src.utils.disk import *
from src.utils.net import *
import json
import os
config_data = {}
def get_boot_os_list():
return config_data.get('os_list', [])
def probe_partitions():
os_list = []
for disknum, disk in enumerate(get_disks(), start=1):
try:
partitions = get_partition_data(device=f'/dev/{disk}')
except Exception as e:
continue
for pa in partitions:
partnum = pa.partno + 1
mountpoint = pa.padev.replace('dev', 'mnt')
if mount_mkdir(pa.padev, mountpoint):
try:
os_family = get_os_family(mountpoint)
if os_family == OSFamily.UNKNOWN:
continue
os_name = os_probe(mountpoint)
os_data = {
'name': os_name,
'partition': partnum,
'disk': disknum,
'os': os_family,
'image': ''
}
os_list.append(os_data)
finally:
umount(mountpoint)
config_data['os_list'] = os_list
def get_main_color_theme():
color_values = config_data.get('color_theme', [49, 69, 106])
return QColor(*color_values)
def side_panel_enabled():
return config_data.get('side_panel', True) and not is_busy_enabled()
def get_status():
return config_data.get('status', 'idle')
def set_status(value):
config_data['status'] = value
def is_busy_enabled():
return config_data.get('status', 'idle') == 'busy'
def get_ip():
return getifaddr(get_ethernet_interface())
def get_log_path():
return f'/opt/opengnsys/log/{get_ip()}.log'
def get_os_columns():
return config_data.get('os_columns', 2)

View File

@ -0,0 +1,16 @@
The following files belong to the Font-Awesome project:
compact-disc.svg
display.svg
rotate-right.svg
chart-simple.svg
power-off.svg
linux.svg
windows.svg
The png versions are derived from the original svg files with the same name.
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
The Font Awesome Free download is licensed under a Creative Commons
Attribution 4.0 International License and applies to all icons packaged
as SVG and JS file types.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

217
src/kiosk/kiosk.py 100644
View File

@ -0,0 +1,217 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from PyQt6.QtCore import (
Qt, QPropertyAnimation, QRect, QFile, QTextStream, QTranslator,
QLocale, QSocketNotifier
)
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout
)
import sys
from src.kiosk.sidepanel import SidePanel, ViewType
import src.kiosk.thirdparty.breeze.breeze_pyqt6
from src.kiosk.spinner import *
from src.kiosk.config import *
SIDE_PANEL_WIDTH = 200
class Kiosk(QMainWindow):
def __init__(self, socket):
super().__init__()
self.side_panel = None
self.spinner = None
# socket to read/send events from/to ogClient
self.socket = socket
if socket:
self.socket_notifier = QSocketNotifier(
self.socket.fileno(), QSocketNotifier.Type.Read
)
self.socket_notifier.activated.connect(self.read_from_socket)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setWindowTitle('Kiosk')
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QHBoxLayout()
central_widget.setLayout(layout)
self.main_content = QWidget()
self.main_content.setLayout(QVBoxLayout())
layout.addWidget(self.main_content)
self.reload_theme()
def closeEvent(self, event):
if self.socket_notifier:
self.socket_notifier.setEnabled(False)
self.socket_notifier.disconnect()
self.socket_notifier.deleteLater()
if self.socket:
self.socket.close()
super().closeEvent(event)
def send_to_ogclient(self, data):
try:
json_data = json.dumps(data)
self.socket.send(json_data.encode('utf-8'))
except TypeError as e:
print(f'Failed to encode data to JSON: {e}')
except Exception as e:
print(f'Unexpected error in send_kiosk_event: {e}')
def read_from_socket(self):
try:
data = self.socket.recv(1024).decode('utf-8')
if not data:
return # Socket closed
payload = json.loads(data)
if payload.get('command') == 'state':
status = payload.get('status', 'idle')
self.apply_busy_mode_configuration(status)
elif payload.get('command') == 'refresh':
self.reload_theme()
elif payload.get('command') == 'close':
self.close()
except OSError as e:
print(f'Failed to read from socket: {e}')
except json.JSONDecodeError as e:
print(f'Failed to decode JSON: {e}')
def reload_theme(self):
# Floating side panel
if self.side_panel:
self.side_panel.setParent(None)
self.side_panel.deleteLater()
self.side_panel = SidePanel()
self.side_panel.setParent(self)
self.side_panel.setFixedWidth(SIDE_PANEL_WIDTH)
self.side_panel.panel_hidden = True
self.side_panel.setGeometry(-SIDE_PANEL_WIDTH, 0, SIDE_PANEL_WIDTH, self.height())
self.side_panel.widget_change_requested.connect(self.update_view)
self.side_panel.emit_default_widget()
self.side_panel.setVisible(True)
self.side_panel.raise_()
self.animation = QPropertyAnimation(self.side_panel, b'geometry')
self.animation.setDuration(200)
def apply_busy_mode_configuration(self, status):
if get_status() == status:
return
set_status(status)
if is_busy_enabled():
self.toggle_panel()
self.side_panel.request_widget_change(ViewType.SYSTEM_MONITOR)
self.spinner = LoadSpinner(self)
self.spinner.setHeight(30)
self.spinner.setColor(get_main_color_theme())
self.spinner.start()
self.spinner.show()
self.spinner.setToolTip(self.tr('The client is busy, please wait...'))
return
if self.spinner:
self.spinner.setParent(None)
self.spinner.deleteLater()
def keyPressEvent(self, event):
if side_panel_enabled() and event.key() == Qt.Key.Key_F1:
self.side_panel.request_widget_change(ViewType.SYSTEM_MONITOR)
elif side_panel_enabled() and event.key() == Qt.Key.Key_F2:
self.side_panel.request_widget_change(ViewType.BOOT_OS)
elif event.key() == Qt.Key.Key_P:
self.toggle_panel()
else:
super().keyPressEvent(event)
def toggle_panel(self):
if self.side_panel.panel_hidden and side_panel_enabled():
# Show the panel
self.animation.setStartValue(self.side_panel.geometry())
self.animation.setEndValue(QRect(0, 0, SIDE_PANEL_WIDTH, self.height()))
self.side_panel.panel_hidden = False
else:
# Hide the panel
self.animation.setStartValue(self.side_panel.geometry())
self.animation.setEndValue(QRect(-SIDE_PANEL_WIDTH, 0, SIDE_PANEL_WIDTH, self.height()))
self.side_panel.panel_hidden = True
self.animation.start()
def resizeEvent(self, event):
super().resizeEvent(event)
# Adjust panel geometry based on visibility
if self.side_panel.panel_hidden:
self.side_panel.setGeometry(-SIDE_PANEL_WIDTH, 0, SIDE_PANEL_WIDTH, self.height())
else:
self.side_panel.setGeometry(0, 0, SIDE_PANEL_WIDTH, self.height())
def update_view(self, widget):
layout = self.main_content.layout()
# Clear existing widgets
for i in reversed(range(layout.count())):
widget_item = layout.itemAt(i).widget()
if widget_item:
widget_item.setParent(None)
widget_item.deleteLater()
layout.addWidget(widget)
widget.command_requested.connect(self.send_to_ogclient)
def closeEvent(self, event):
logging.info("Closing Kiosk application...")
if self.socket_notifier:
self.socket_notifier.setEnabled(False)
if self.socket:
self.socket.close()
event.accept()
def launch_kiosk(socket, debug_mode):
app = QApplication(sys.argv)
translator = QTranslator()
locale = QLocale.system().name()
translation_file = f'kiosk_{locale}.qm'
translations_dir = os.path.join(
os.path.dirname(__file__),
'translations')
if translator.load(translation_file, translations_dir):
app.installTranslator(translator)
file = QFile(':/light/stylesheet.qss')
file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text)
stream = QTextStream(file)
app.setStyleSheet(stream.readAll())
gui = Kiosk(socket)
if debug_mode:
gui.showMaximized()
else:
gui.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window)
gui.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
gui.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
gui.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
screen = QApplication.primaryScreen()
geometry = screen.availableGeometry()
gui.setGeometry(geometry)
gui.show()
return app.exec()

View File

@ -0,0 +1,478 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from PyQt6.QtWidgets import (
QSizePolicy, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QProgressBar,
QFrame, QTextEdit
)
from PyQt6.QtCore import (
QTimer, Qt, QFileSystemWatcher, QRegularExpression, pyqtSignal
)
from PyQt6.QtGui import (
QTextCharFormat, QColor, QFont, QTextCursor, QSyntaxHighlighter
)
from collections import defaultdict, deque
from src.kiosk.config import *
from src.utils.net import *
import psutil
import socket
import sys
import os
POLLING_INTERVAL = 500
MAX_ITEM_WIDTH = 300
class CPUMonitor(QFrame):
def __init__(self):
super().__init__()
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
frame_layout = QVBoxLayout()
self.setLayout(frame_layout)
self.cpu_label = QLabel()
self.cpu_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.cpu_bar = QProgressBar()
self.cpu_bar.setFixedWidth(MAX_ITEM_WIDTH)
title_label = QLabel(self.tr('CPU'))
title_label.setStyleSheet('font-weight: 600;')
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
frame_layout.addWidget(title_label)
frame_layout.addWidget(self.cpu_label)
frame_layout.addStretch()
frame_layout.addWidget(self.cpu_bar)
frame_layout.setAlignment(self.cpu_bar, Qt.AlignmentFlag.AlignCenter)
def update_metrics(self):
cpu_percent = psutil.cpu_percent(interval=0)
self.cpu_bar.setValue(int(cpu_percent))
self.cpu_label.setText(self.tr('CPU Usage: {cpu_percent}%')
.format(cpu_percent=cpu_percent))
class MemoryMonitor(QFrame):
def __init__(self):
super().__init__()
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
frame_layout = QVBoxLayout()
self.setLayout(frame_layout)
self.memory_label = QLabel()
self.memory_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.memory_bar = QProgressBar()
self.memory_bar.setFixedWidth(MAX_ITEM_WIDTH)
title_label = QLabel(self.tr('MEMORY'))
title_label.setStyleSheet('font-weight: 600;')
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
frame_layout.addWidget(title_label)
frame_layout.addWidget(self.memory_label)
frame_layout.addStretch()
frame_layout.addWidget(self.memory_bar)
frame_layout.setAlignment(self.memory_bar, Qt.AlignmentFlag.AlignCenter)
def update_metrics(self):
memory = psutil.virtual_memory()
memory_percent = memory.percent
self.memory_bar.setValue(int(memory_percent))
total_mib = memory.total // (1024**2)
available_mib = memory.available // (1024**2)
buffers_mib = memory.buffers // (1024**2)
cached_mib = memory.cached // (1024**2)
buffcache_mib = buffers_mib + cached_mib
used_mib = total_mib - available_mib
self.memory_label.setText(self.tr(
'Total: {total_mib} MiB\n'
'Used: {used_mib} MiB\n'
'Buff/Cache: {buffcache_mib} MiB'
).format(
total_mib=total_mib,
used_mib=used_mib,
buffcache_mib=buffcache_mib
))
class BaseMonitorMultiWidget(QFrame):
def __init__(self):
super().__init__()
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.layout = QVBoxLayout()
self.setLayout(self.layout)
self.title_label = QLabel(self.tr('MONITOR'))
self.title_label.setStyleSheet('font-weight: 600;')
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.title_label)
self.data_layout = QHBoxLayout()
self.layout.addLayout(self.data_layout)
# Dictionary to track widgets by name
self.widget_map = {}
def update_widgets(self, data):
# Remove widgets that are no longer in the data
existing_names = set(self.widget_map.keys())
current_names = set(data.keys())
for name in existing_names - current_names:
self.remove_widget(name)
# Update or create widgets for current data
for name, info in data.items():
if name in self.widget_map:
self.update_widget(self.widget_map[name], name, info)
else:
self.add_widget(name, info)
def add_widget(self, name, info):
widget = self.create_widget(name, info)
self.widget_map[name] = widget
self.data_layout.addWidget(widget)
def remove_widget(self, name):
widget = self.widget_map.pop(name, None)
if widget:
self.data_layout.removeWidget(widget)
widget.setParent(None)
widget.deleteLater()
def create_widget(self, name, info):
raise NotImplementedError("Subclasses must implement create_widget.")
def update_widget(self, widget, name, info):
raise NotImplementedError("Subclasses must implement update_widget.")
class DiskMonitor(BaseMonitorMultiWidget):
def __init__(self):
super().__init__()
self.title_label.setText(self.tr('DISK USAGE'))
self.prev_disk_io = psutil.disk_io_counters(perdisk=True)
def collect_data(self):
data = {}
current_disk_io = psutil.disk_io_counters(perdisk=True)
# Collect disk usage information
disk_usage = defaultdict(lambda: {'total': 0, 'used': 0})
partitions = psutil.disk_partitions(all=False)
for partition in partitions:
try:
usage = psutil.disk_usage(partition.mountpoint)
device = os.path.basename(partition.device).rstrip('1234567890')
if not (device.startswith('sd') or device.startswith('nvme') or device.startswith('vd')):
continue
disk_usage[device]['total'] += usage.total
disk_usage[device]['used'] += usage.used
except PermissionError:
continue
# Combine usage and I/O statistics
for disk, usage in disk_usage.items():
if disk in self.prev_disk_io and disk in current_disk_io:
read_bytes = current_disk_io[disk].read_bytes - self.prev_disk_io[disk].read_bytes
write_bytes = current_disk_io[disk].write_bytes - self.prev_disk_io[disk].write_bytes
data[disk] = {
'total_bytes': usage['total'],
'used_bytes': usage['used'],
'read_bytes_per_sec': read_bytes / 1024,
'write_bytes_per_sec': write_bytes / 1024,
}
# Update previous disk I/O stats for the next cycle
self.prev_disk_io = current_disk_io
return data
def create_widget(self, name, info) -> QWidget:
widget = QWidget()
widget.setMaximumWidth(MAX_ITEM_WIDTH + 12)
layout = QVBoxLayout()
widget.setLayout(layout)
label = QLabel()
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
progress_bar = QProgressBar()
progress_bar.setMaximumWidth(MAX_ITEM_WIDTH)
progress_bar.setMaximum(100)
layout.addWidget(progress_bar)
# Attach data for updates
widget.label = label
widget.progress_bar = progress_bar
return widget
def update_widget(self, widget, name, info):
total_gib = info['total_bytes'] / (1024 ** 3)
used_gib = info['used_bytes'] / (1024 ** 3)
read_kib = info['read_bytes_per_sec']
write_kib = info['write_bytes_per_sec']
widget.label.setText(self.tr(
'Device: /dev/{name}\n'
'Total: {total_gib:.1f} GiB\n'
'Used: {used_gib:.1f} GiB\n'
'Read: {read_kib:6.1f} KiB/s\n'
'Write: {write_kib:6.1f} KiB/s'
).format(
name=name,
total_gib=total_gib,
used_gib=used_gib,
read_kib=read_kib,
write_kib=write_kib
))
usage_percent = (info['used_bytes'] / info['total_bytes']) * 100
widget.progress_bar.setValue(int(usage_percent))
def update_metrics(self):
data = self.collect_data()
self.update_widgets(data)
class NetworkMonitor(BaseMonitorMultiWidget):
def __init__(self):
super().__init__()
self.title_label.setText(self.tr('NETWORK'))
self.prev_net_io = psutil.net_io_counters(pernic=True)
def collect_data(self):
current_net_io = psutil.net_io_counters(pernic=True)
interfaces = psutil.net_if_addrs()
stats = psutil.net_if_stats()
data = {}
for iface, addrs in interfaces.items():
if not stats[iface].isup or iface == 'lo':
continue
if iface in self.prev_net_io and iface in current_net_io:
prev_io = self.prev_net_io[iface]
curr_io = current_net_io[iface]
bytes_recv_rate = (curr_io.bytes_recv - prev_io.bytes_recv) / 1024
bytes_sent_rate = (curr_io.bytes_sent - prev_io.bytes_sent) / 1024
else:
bytes_recv_rate = bytes_sent_rate = 0
ipv4 = ipv6 = 'N/A'
for addr in addrs:
if addr.family == socket.AF_INET:
ipv4 = addr.address
elif addr.family == socket.AF_INET6 and not is_link_local_ipv6(addr.address):
ipv6 = addr.address.split('%')[0]
data[iface] = {
'ipv4': ipv4,
'ipv6': ipv6,
'recv_rate': bytes_recv_rate,
'send_rate': bytes_sent_rate,
}
self.prev_net_io = current_net_io
return data
def create_widget(self, name, info) -> QWidget:
widget = QWidget()
layout = QVBoxLayout()
widget.setLayout(layout)
label = QLabel()
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
# Attach data for updates
widget.label = label
widget.progress_bar = None
return widget
def update_widget(self, widget, name, info):
widget.label.setText(self.tr(
'Interface: {name}\n'
'IPv4: {ipv4}\n'
'{recv_rate:6.1f} KiB/s\n'
'{send_rate:6.1f} KiB/s'
).format(
name=name,
ipv4=info['ipv4'],
recv_rate=info['recv_rate'],
send_rate=info['send_rate']
))
def update_metrics(self):
data = self.collect_data()
self.update_widgets(data)
class LogHighlighter(QSyntaxHighlighter):
def __init__(self, parent):
super().__init__(parent)
self.highlight_rules = []
patterns = [
(r'\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\)', QColor('darkblue'), QFont.Weight.Normal), # Timestamp
(r'\S+[\\/]\S+', QColor('black'), 420),
(r'ogClient', QColor('blue'), QFont.Weight.Normal),
(r'\[INFO\]', QColor('darkgreen'), QFont.Weight.DemiBold),
(r'\[DEBUG\]', QColor('navy'), QFont.Weight.DemiBold),
(r'\[WARNING\]', QColor('orangered'), QFont.Weight.DemiBold),
(r'\[ERROR\]', QColor('red'), QFont.Weight.DemiBold),
]
for pattern, color, weight in patterns:
fmt = QTextCharFormat()
fmt.setForeground(color)
fmt.setFontWeight(weight)
self.highlight_rules.append((QRegularExpression(pattern), fmt))
def highlightBlock(self, text):
for pattern, fmt in self.highlight_rules:
match_iter = pattern.globalMatch(text)
while match_iter.hasNext():
match = match_iter.next()
self.setFormat(match.capturedStart(), match.capturedLength(), fmt)
class LogViewWidget(QFrame):
MAX_LOG_LINES = 300
AUTOSCROLL_THRESHOLD = 50
def __init__(self, log_file_path):
super().__init__()
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.log_file_path = log_file_path
self.setMinimumWidth(300)
self.layout = QVBoxLayout()
self.setLayout(self.layout)
title_label = QLabel(self.tr('LOGS'))
title_label.setStyleSheet('font-weight: 600;')
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(title_label)
self.log_display = QTextEdit(self)
self.log_display.setReadOnly(True)
self.highlighter = LogHighlighter(self.log_display.document())
self.layout.addWidget(self.log_display)
self.file_watcher = QFileSystemWatcher(self)
self.file_watcher.fileChanged.connect(self.on_file_changed)
self.file_watcher.addPath(self.log_file_path)
self.user_scrolling = False
self.log_display.verticalScrollBar().valueChanged.connect(self.on_scroll)
self.read_log_file()
self.log_display.moveCursor(self.log_display.textCursor().MoveOperation.End)
def read_last_lines(file_path, num_lines):
with open(file_path, 'r') as log_file:
last_lines = deque(log_file, maxlen=num_lines)
return ''.join(last_lines)
def read_log_file(self):
try:
new_content = LogViewWidget.read_last_lines(
self.log_file_path, self.MAX_LOG_LINES)
except Exception as e:
self.log_display.append(self.tr('Error reading file: {error}').format(error=e))
return
scroll_bar = self.log_display.verticalScrollBar()
previous_value = scroll_bar.value()
scroll_bar.valueChanged.disconnect(self.on_scroll)
# Update the log display
current_content = self.log_display.toPlainText()
if new_content != current_content:
self.log_display.setPlainText(new_content)
# Restore or update scrolling based on user activity
if self.user_scrolling:
scroll_bar.setValue(previous_value)
else:
self.log_display.moveCursor(self.log_display.textCursor().MoveOperation.End)
scroll_bar.valueChanged.connect(self.on_scroll)
def on_scroll(self, value):
scroll_bar = self.log_display.verticalScrollBar()
if value < (scroll_bar.maximum() - self.AUTOSCROLL_THRESHOLD):
self.user_scrolling = True
else:
self.user_scrolling = False
def on_file_changed(self, path):
if path == self.log_file_path:
self.read_log_file()
class MonitorView(QWidget):
command_requested = pyqtSignal(dict)
def request_command(self, payload):
self.data_emitted.emit(payload)
def __init__(self):
super().__init__()
self.layout = QHBoxLayout()
self.monitor_layout = QVBoxLayout()
self.layout0 = QHBoxLayout()
self.monitor_layout.addLayout(self.layout0)
# Memory Usage
self.mem_monitor = MemoryMonitor()
self.layout0.addWidget(self.mem_monitor)
# CPU Usage
self.cpu_monitor = CPUMonitor()
self.layout0.addWidget(self.cpu_monitor)
# Disk Usage
self.disk_monitor = DiskMonitor()
self.monitor_layout.addWidget(self.disk_monitor)
# Network Usage
self.net_monitor = NetworkMonitor()
self.monitor_layout.addWidget(self.net_monitor)
self.monitor_layout.addStretch(1)
self.layout.addLayout(self.monitor_layout)
# Timer to refresh the data
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_metrics)
self.timer.start(POLLING_INTERVAL)
self.update_metrics()
log_widget = LogViewWidget(get_log_path())
self.layout.addWidget(log_widget)
self.setLayout(self.layout)
def update_metrics(self):
self.cpu_monitor.update_metrics()
# Memory usage
self.mem_monitor.update_metrics()
# Disk usage
self.disk_monitor.update_metrics()
# Network usage
self.net_monitor.update_metrics()

View File

@ -0,0 +1,81 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from PyQt6.QtCore import Qt, pyqtSignal, QCoreApplication
from PyQt6.QtWidgets import (
QWidget, QPushButton, QVBoxLayout, QFrame
)
from PyQt6.QtGui import QPixmap, QIcon
from src.kiosk.monitor import MonitorView
from src.kiosk.boot import BootView
from src.kiosk.config import *
from src.kiosk.theme import *
from enum import Enum
class ViewType(Enum):
SYSTEM_MONITOR = 1
BOOT_OS = 2
def get_view_title(self):
if self == ViewType.SYSTEM_MONITOR:
return QCoreApplication.translate('ViewType', 'System monitor')
elif self == ViewType.BOOT_OS:
return QCoreApplication.translate('ViewType', 'Boot OS')
else:
return ''
def get_view_icon(self):
if self == ViewType.SYSTEM_MONITOR:
icon_name = 'chart-simple.png'
elif self == ViewType.BOOT_OS:
icon_name = 'display.png'
else:
return QIcon()
return get_icon(icon_name)
def get_widget_instance(self):
if self == ViewType.SYSTEM_MONITOR:
return MonitorView()
elif self == ViewType.BOOT_OS:
return BootView()
else:
return None
class SidePanel(QFrame):
widget_change_requested = pyqtSignal(QWidget)
def __init__(self):
super().__init__()
self.panel_hidden = False
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.setLayout(layout)
for view_type in ViewType:
button = QPushButton()
button.setText(' ' + view_type.get_view_title())
button.setStyleSheet('height: 40px;')
button.setIcon(view_type.get_view_icon())
layout.addWidget(button)
button.clicked.connect(lambda _, view=view_type: self.request_widget_change(view))
def request_widget_change(self, view_type):
self.widget_change_requested.emit(view_type.get_widget_instance())
def emit_default_widget(self):
self.request_widget_change(ViewType.BOOT_OS)
def emit_monitor_widget(self):
self.request_widget_change(ViewType.SYSTEM_MONITOR)

View File

@ -0,0 +1,87 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from PyQt6.QtCore import Qt, QTimer, QRect
from PyQt6.QtGui import QPainter, QPen
from PyQt6.QtWidgets import QApplication, QWidget
class LoadSpinner(QWidget):
OFFSET = 5
def __init__(self, parent=None):
super().__init__(parent)
self.span = 0
self.growing = True
self.color = Qt.GlobalColor.blue
self.start_angle = 0
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
size = 50
self.setFixedSize(size, size)
self.updateFrame()
self.timer = QTimer(self)
self.timer.timeout.connect(self.rotate)
self.timer.setInterval(30)
def setColor(self, color):
self.color = color
def setWidth(self, w):
self.setFixedSize(w, w)
self.updateFrame()
def setHeight(self, h):
self.setFixedSize(h, h)
self.updateFrame()
def start(self):
self.timer.start()
def stop(self):
self.timer.stop()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen(self.color)
pen.setWidth(self.height() // 10)
painter.setPen(pen)
painter.setOpacity(0.2)
painter.drawArc(self.frame, 0, 5760)
painter.setOpacity(1.0)
painter.drawArc(self.frame, self.start_angle * 16, self.span * 16)
def rotate(self):
advance = 3
grow = 8
if self.growing:
self.start_angle = (self.start_angle + advance) % 360
self.span += grow
if self.span > 260:
self.growing = False
else:
self.start_angle = (self.start_angle + grow) % 360
self.span = self.span + advance - grow
if self.span < 10:
self.growing = True
self.update()
def updateFrame(self):
self.frame = QRect(
self.OFFSET,
self.OFFSET,
self.width() - self.OFFSET * 2,
self.height() - self.OFFSET * 2,
)

45
src/kiosk/theme.py 100644
View File

@ -0,0 +1,45 @@
# Copyright (C) 2020-2025 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
from src.kiosk.config import *
from PyQt6.QtGui import QPixmap, QPainter, QColor, QIcon
from PyQt6.QtCore import Qt
import os
BUTTON_HEIGHT = 55
BUTTON_WIDTH = 390
BUTTON_ICON_SIZE = 25
POWER_ICON_SIZE = 50
def colorize_pixmap(pixmap, color):
# Create a new pixmap with the same size
colorized_pixmap = QPixmap(pixmap.size())
colorized_pixmap.fill(Qt.GlobalColor.transparent)
# Paint the original pixmap and apply the color overlay
painter = QPainter(colorized_pixmap)
painter.drawPixmap(0, 0, pixmap)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(colorized_pixmap.rect(), color)
painter.end()
return colorized_pixmap
def get_icon(icon_name):
icon_pixmap = get_image(icon_name)
return QIcon(colorize_pixmap(icon_pixmap, get_main_color_theme()))
def get_image(image_name):
base_dir = base_dir = os.path.abspath(
os.path.dirname(__file__)
)
img_path = os.path.join(base_dir, 'img', image_name)
pixmap = QPixmap(img_path)
if pixmap.isNull():
print(f'Could not load {img_path}')
return pixmap

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="ca_ES" sourcelanguage="en_US">
<context>
<name>BaseMonitorMultiWidget</name>
<message>
<location filename="../monitor.py" line="96"/>
<source>MONITOR</source>
<translation>MONITOR</translation>
</message>
</context>
<context>
<name>BootView</name>
<message>
<location filename="../boot.py" line="42"/>
<source> Restore</source>
<translation> Restaurar</translation>
</message>
<message>
<location filename="../boot.py" line="43"/>
<source> Boot</source>
<translation> Iniciar</translation>
</message>
</context>
<context>
<name>CPUMonitor</name>
<message>
<location filename="../monitor.py" line="40"/>
<source>CPU</source>
<translation>CPU</translation>
</message>
<message>
<location filename="../monitor.py" line="52"/>
<source>CPU Usage: {cpu_percent}%</source>
<translation>Ús de la CPU: {cpu_percent}%</translation>
</message>
</context>
<context>
<name>DiskMonitor</name>
<message>
<location filename="../monitor.py" line="144"/>
<source>DISK USAGE</source>
<translation>ÚS DEL DISC</translation>
</message>
<message>
<location filename="../monitor.py" line="208"/>
<source>Device: /dev/{name}
Total: {total_gib:.1f} GiB
Used: {used_gib:.1f} GiB
Read: {read_kib:.1f} KiB/s
Write: {write_kib:.1f} KiB/s</source>
<translation>Dispositiu: /dev/{name}
Total: {total_gib:.1f} GiB
Usat: {used_gib:.1f} GiB
Lectura: {read_kib:.1f} KiB/s
Escriptura: {write_kib:.1f} KiB/s</translation>
</message>
</context>
<context>
<name>Kiosk</name>
<message>
<location filename="../kiosk.py" line="124"/>
<source>The client is busy, please wait...</source>
<translation>El client està ocupat, si us plau, espereu...</translation>
</message>
</context>
<context>
<name>LogViewWidget</name>
<message>
<location filename="../monitor.py" line="316"/>
<source>LOGS</source>
<translation>LOGS</translation>
</message>
<message>
<location filename="../monitor.py" line="351"/>
<source>Error reading file: {error}</source>
<translation>Error en llegir el fitxer: {error}</translation>
</message>
</context>
<context>
<name>MemoryMonitor</name>
<message>
<location filename="../monitor.py" line="67"/>
<source>MEMORY</source>
<translation>MEMÒRIA</translation>
</message>
<message>
<location filename="../monitor.py" line="80"/>
<source>Total: {total_mib} MiB
Available: {available_mib} MiB</source>
<translation>Total: {total_mib} MiB
Disponible: {available_mib} MiB</translation>
</message>
</context>
<context>
<name>NetworkMonitor</name>
<message>
<location filename="../monitor.py" line="233"/>
<source>NETWORK</source>
<translation>XARXA</translation>
</message>
<message>
<location filename="../monitor.py" line="286"/>
<source>Interface: {name}
IPv4: {ipv4}
{recv_rate:.1f} KiB/s {send_rate:.1f} KiB/s</source>
<translation>Interface: {name}
IPv4: {ipv4}
{recv_rate:.1f} KiB/s {send_rate:.1f} KiB/s</translation>
</message>
</context>
<context>
<name>ViewType</name>
<message>
<location filename="../sidepanel.py" line="26"/>
<source>System monitor</source>
<translation>Monitor de sistema</translation>
</message>
<message>
<location filename="../sidepanel.py" line="28"/>
<source>Boot OS</source>
<translation>Iniciar SO</translation>
</message>
</context>
</TS>

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="es_ES" sourcelanguage="en_US">
<context>
<name>BaseMonitorMultiWidget</name>
<message>
<location filename="../monitor.py" line="96"/>
<source>MONITOR</source>
<translation>MONITOR</translation>
</message>
</context>
<context>
<name>BootView</name>
<message>
<location filename="../boot.py" line="42"/>
<source> Restore</source>
<translation> Restaurar</translation>
</message>
<message>
<location filename="../boot.py" line="43"/>
<source> Boot</source>
<translation> Arrancar</translation>
</message>
</context>
<context>
<name>CPUMonitor</name>
<message>
<location filename="../monitor.py" line="40"/>
<source>CPU</source>
<translation>CPU</translation>
</message>
<message>
<location filename="../monitor.py" line="52"/>
<source>CPU Usage: {cpu_percent}%</source>
<translation>Uso de CPU: {cpu_percent}%</translation>
</message>
</context>
<context>
<name>DiskMonitor</name>
<message>
<location filename="../monitor.py" line="144"/>
<source>DISK USAGE</source>
<translation>USO DE DISCO</translation>
</message>
<message>
<location filename="../monitor.py" line="208"/>
<source>Device: /dev/{name}
Total: {total_gib:.1f} GiB
Used: {used_gib:.1f} GiB
Read: {read_kib:.1f} KiB/s
Write: {write_kib:.1f} KiB/s</source>
<translation>Dispositivo: /dev/{name}
Total: {total_gib:.1f} GiB
Usado: {used_gib:.1f} GiB
Lectura: {read_kib:.1f} KiB/s
Escritura: {write_kib:.1f} KiB/s</translation>
</message>
</context>
<context>
<name>Kiosk</name>
<message>
<location filename="../kiosk.py" line="124"/>
<source>The client is busy, please wait...</source>
<translation>El cliente está ocupado, por favor espere...</translation>
</message>
</context>
<context>
<name>LogViewWidget</name>
<message>
<location filename="../monitor.py" line="316"/>
<source>LOGS</source>
<translation>LOGS</translation>
</message>
<message>
<location filename="../monitor.py" line="351"/>
<source>Error reading file: {error}</source>
<translation>Error leyendo archivo: {error}</translation>
</message>
</context>
<context>
<name>MemoryMonitor</name>
<message>
<location filename="../monitor.py" line="67"/>
<source>MEMORY</source>
<translation>MEMORIA</translation>
</message>
<message>
<location filename="../monitor.py" line="80"/>
<source>Total: {total_mib} MiB
Available: {available_mib} MiB</source>
<translation>Total: {total_mib} MiB
Disponible: {available_mib} MiB</translation>
</message>
</context>
<context>
<name>NetworkMonitor</name>
<message>
<location filename="../monitor.py" line="233"/>
<source>NETWORK</source>
<translation>RED</translation>
</message>
<message>
<location filename="../monitor.py" line="286"/>
<source>Interface: {name}
IPv4: {ipv4}
{recv_rate:.1f} KiB/s {send_rate:.1f} KiB/s</source>
<translation>Interface: {name}
IPv4: {ipv4}
{recv_rate:.1f} KiB/s {send_rate:.1f} KiB/s</translation>
</message>
</context>
<context>
<name>ViewType</name>
<message>
<location filename="../sidepanel.py" line="26"/>
<source>System monitor</source>
<translation>Monitor de sistema</translation>
</message>
<message>
<location filename="../sidepanel.py" line="28"/>
<source>Boot OS</source>
<translation>Arranque SO</translation>
</message>
</context>
</TS>

View File

@ -298,6 +298,17 @@ class OgLiveOperations:
image_path = self._fetch_image_tiptorrent(repo, name)
self._restore_image(image_path, devpath)
def _restore_image_cache(self, name, devpath):
if not get_cache_dev_path():
raise OgError('No cache partition is mounted')
image_path = f'{OG_CACHE_IMAGE_PATH}{name}.img'
if (not os.path.exists(image_path)):
raise OgError(f'could not find {image_path} in cache')
self._restore_image(image_path, devpath)
def _restore_image(self, image_path, devpath):
logging.info(f'Restoring image at {image_path} into {devpath}')
logging.info('*DO NOT REBOOT OR POWEROFF* the client during this time')
@ -572,18 +583,23 @@ class OgLiveOperations:
disk = int(request.getDisk())
partition = int(request.getPartition())
name = request.getName().removesuffix('.img')
repo = request.getRepo()
ctype = request.getType()
profile = request.getProfile()
cid = request.getId()
partdev = get_partition_device(disk, partition)
is_cache_only = (ctype == 'CACHE')
if not is_cache_only:
repo = request.getRepo()
profile = request.getProfile()
cid = request.getId()
partdev = get_partition_device(disk, partition)
mount_cache()
self._ogbrowser_clear_logs()
self._restartBrowser(self._url_log)
logging.info(f'Request to restore image {name}.img via {ctype} from {repo}')
if is_cache_only:
logging.info(f'Request to restore image {name}.img via {ctype}')
else:
logging.info(f'Request to restore image {name}.img via {ctype} from {repo}')
if shutil.which('restoreImageCustom'):
logging.warning(f'Ignoring restoreImageCustom, use postconfiguration scripts instead.')
@ -593,6 +609,8 @@ class OgLiveOperations:
self._restore_image_unicast(repo, name, partdev, cache)
elif ctype == 'TIPTORRENT':
self._restore_image_tiptorrent(repo, name, partdev)
elif is_cache_only:
self._restore_image_cache(name, partdev)
extend_filesystem(disk, partition)

View File

@ -40,8 +40,13 @@ class ogClient:
self.event_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.event_sock.setblocking(0)
self.event_sock.bind(('127.0.0.1', ogClient.EVENT_SOCKET_PORT))
self.kiosk_sock = None
elif self.mode in {'live'}:
self.event_sock = None
self.kiosk_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)
else:
self.event_sock = None
self.kiosk_sock = None
if self.CONFIG['samba']['activate']:
assert('user' in self.CONFIG['samba'])
@ -78,6 +83,16 @@ class ogClient:
def get_event_socket(self):
return self.event_sock
def get_kiosk_socket_0(self):
if self.kiosk_sock:
return self.kiosk_sock[0]
return None
def get_kiosk_socket_1(self):
if self.kiosk_sock:
return self.kiosk_sock[1]
return None
def get_state(self):
return self.state
@ -99,6 +114,51 @@ class ogClient:
self.send(response.get())
logging.debug('Sending event OK')
def handle_kiosk_event(self, data):
payload = json.loads(data)
command = payload.get('command', 'unknown')
logging.info(f'Received {command} command from kiosk')
if self.ogrest.is_busy() and not (command == 'poweroff' or command == 'reboot'):
logging.error('Request has been received while ogClient is busy')
return
report_kiosk_busy = (command != 'poweroff' and command != 'reboot')
self.ogrest.set_state(ThreadState.BUSY, self, update_kiosk=report_kiosk_busy)
if command == 'poweroff':
threading.Thread(target=ogThread.poweroff, args=(self.ogrest,)).start()
elif command == 'reboot':
threading.Thread(target=ogThread.reboot, args=(self.ogrest,)).start()
elif command == 'boot':
request = restRequest()
request.partition = payload.get('partition')
request.disk = payload.get('disk')
threading.Thread(target=ogThread.boot_os_local, args=(self, request, self.ogrest,)).start()
elif command == 'restore':
request = restRequest()
request.name = payload.get('image')
request.partition = payload.get('partition')
request.disk = payload.get('disk')
request.type = 'CACHE'
threading.Thread(target=ogThread.image_restore_local, args=(self, request, self.ogrest,)).start()
else:
logging.info(f'Received unknown command ·{command}" from kiosk')
self.ogrest.set_state(ThreadState.IDLE, self)
def send_kiosk_event(self, data):
kiosk_sock = self.get_kiosk_socket_0()
if not kiosk_sock:
return
logging.info(f'Sending Kiosk event: {data}')
try:
json_data = json.dumps(data)
kiosk_sock.send(json_data.encode("utf-8"))
except TypeError as e:
logging.error(f"Failed to encode data to JSON: {e}")
except Exception as e:
logging.error(f"Unexpected error in send_kiosk_event: {e}")
def cleanup(self):
self.data = ""
self.content_len = 0
@ -152,7 +212,7 @@ class ogClient:
if len(data) == 0:
self.sock.close()
self.ogrest.kill_process()
self.ogrest.kill_process(self)
self.connect()
return
@ -185,10 +245,18 @@ class ogClient:
self.sock.shutdown(socket.SHUT_RDWR)
self.sock.close()
def shutdown(self):
self.sock.close()
if self.event_sock:
self.even_sock.close()
if self.kiosk_sock:
self.kiosk_sock[0].close()
def run(self):
while 1:
sock = self.get_socket()
event_sock = self.get_event_socket()
kiosk_sock = self.get_kiosk_socket_0()
state = self.get_state()
if state == State.CONNECTING:
@ -198,7 +266,11 @@ class ogClient:
elif state == State.FORCE_DISCONNECTED:
return 0
else:
readset = [ sock, event_sock ] if event_sock else [ sock ]
readset = [ sock ]
if event_sock:
readset.append(event_sock)
if kiosk_sock:
readset.append(kiosk_sock)
writeset = [ ]
exceptset = [ ]
@ -212,5 +284,8 @@ class ogClient:
elif state == State.RECEIVING and event_sock in readable:
message = event_sock.recv(4096).decode('utf-8').rstrip()
self.handle_session_event(message)
elif state == State.RECEIVING and kiosk_sock in readable:
message = kiosk_sock.recv(4096).decode('utf-8').rstrip()
self.handle_kiosk_event(message)
else:
raise OgError(f'Invalid ogClient run state: {str(state)}.')

View File

@ -88,7 +88,7 @@ class ogThread():
if not request.getrun():
response = restResponse(ogResponses.BAD_REQUEST, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
return
try:
@ -104,7 +104,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def poweroff(ogRest):
time.sleep(2)
@ -113,6 +113,18 @@ class ogThread():
def reboot(ogRest):
ogRest.operations.reboot()
def boot_os_local(client, request, ogRest):
try:
ogRest.operations.session(request, ogRest)
except OgError as e:
logging.error(e)
except Exception as e:
logging.exception(exc)
return
ogRest.set_state(ThreadState.IDLE, client)
client.disconnect()
def session(client, request, ogRest):
try:
ogRest.operations.session(request, ogRest)
@ -137,7 +149,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def hardware(client, ogRest):
try:
@ -151,7 +163,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def setup(client, request, ogRest):
try:
@ -164,7 +176,18 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def image_restore_local(client, request, ogRest):
try:
ogRest.operations.image_restore(request, ogRest)
except OgError as e:
logging.error(e)
except Exception as e:
logging.exception(exc)
return
ogRest.set_state(ThreadState.IDLE, client)
def image_restore(client, request, ogRest):
try:
@ -177,7 +200,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def image_create(client, request, ogRest):
try:
@ -209,7 +232,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def cache_delete(client, request, ogRest):
try:
@ -222,7 +245,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def cache_fetch(client, request, ogRest):
try:
@ -235,7 +258,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client)
def refresh(client, ogRest):
try:
@ -248,7 +271,7 @@ class ogThread():
response = restResponse(ogResponses.OK, json_body, seq=client.seq)
client.send(response.get())
ogRest.state = ThreadState.IDLE
ogRest.set_state(ThreadState.IDLE, client, update_kiosk=False)
class ogResponses(Enum):
BAD_REQUEST=0
@ -288,6 +311,17 @@ class ogRest():
else:
raise OgError(f'Ogrest mode \'{self.mode}\'not supported')
def set_state(self, state, client, update_kiosk=True):
self.state = state
if update_kiosk:
kiosk_status = 'idle'
if self.is_busy():
kiosk_status = 'busy'
client.send_kiosk_event({'command': 'state', 'status': kiosk_status})
def is_busy(self):
return self.state == ThreadState.BUSY
def send_internal_server_error(self, client, exc=None):
if isinstance(exc, OgError):
logging.error(exc)
@ -295,7 +329,7 @@ class ogRest():
logging.exception(exc)
response = restResponse(ogResponses.INTERNAL_ERR, seq=client.seq)
client.send(response.get())
self.state = ThreadState.IDLE
self.set_state(ThreadState.IDLE, client)
def process_request(self, request, client):
method = request.get_method()
@ -306,13 +340,14 @@ class ogRest():
if (not "stop" in URI and
not "reboot" in URI and
not "poweroff" in URI):
if self.state == ThreadState.BUSY:
if self.is_busy():
logging.error('Request has been received while ogClient is busy')
response = restResponse(ogResponses.SERVICE_UNAVAILABLE, seq=client.seq)
client.send(response.get())
return
else:
self.state = ThreadState.BUSY
report_kiosk_busy = ( not "refresh" in URI )
self.set_state(ThreadState.BUSY, client, update_kiosk=report_kiosk_busy)
if ("GET" in method):
if "hardware" in URI:
@ -327,7 +362,7 @@ class ogRest():
logging.error('Unsupported request: %s', {URI[:ogRest.LOG_LENGTH]})
response = restResponse(ogResponses.BAD_REQUEST, seq=client.seq)
client.send(response.get())
self.state = ThreadState.IDLE
self.set_state(ThreadState.IDLE, client)
elif ("POST" in method):
if ("poweroff" in URI):
self.process_poweroff(client)
@ -353,15 +388,15 @@ class ogRest():
logging.error('Unsupported request: %s', URI[:ogRest.LOG_LENGTH])
response = restResponse(ogResponses.BAD_REQUEST, seq=client.seq)
client.send(response.get())
self.state = ThreadState.IDLE
self.set_state(ThreadState.IDLE, client)
else:
response = restResponse(ogResponses.BAD_REQUEST, seq=client.seq)
client.send(response.get())
self.state = ThreadState.IDLE
self.set_state(ThreadState.IDLE, client)
return 0
def kill_process(self):
def kill_process(self, client):
try:
os.kill(self.proc.pid, signal.SIGTERM)
except:
@ -373,7 +408,7 @@ class ogRest():
except:
pass
self.state = ThreadState.IDLE
self.set_state(ThreadState.IDLE, client)
def process_reboot(self, client):
response = restResponse(ogResponses.IN_PROGRESS, seq=client.seq)
@ -381,8 +416,8 @@ class ogRest():
if self.mode != 'virtual':
client.disconnect()
if self.state == ThreadState.BUSY:
self.kill_process()
if self.is_busy():
self.kill_process(client)
threading.Thread(target=ogThread.reboot, args=(self,)).start()
@ -392,8 +427,8 @@ class ogRest():
if self.mode != 'virtual':
client.disconnect()
if self.state == ThreadState.BUSY:
self.kill_process()
if self.is_busy():
self.kill_process(client)
threading.Thread(target=ogThread.poweroff, args=(self,)).start()
@ -412,7 +447,7 @@ class ogRest():
def process_schedule(self, client):
response = restResponse(ogResponses.OK, seq=client.seq)
client.send(response.get())
self.state = ThreadState.IDLE
self.set_state(ThreadState.IDLE, client)
def process_setup(self, client, request):
threading.Thread(target=ogThread.setup, args=(client, request, self,)).start()
@ -422,8 +457,8 @@ class ogRest():
def process_stop(self, client):
client.disconnect()
if self.state == ThreadState.BUSY:
self.kill_process()
if self.is_busy():
self.kill_process(client)
self.terminated = True
sys.exit(0)

View File

@ -6,6 +6,7 @@
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
import ipaddress
import array
import sys
import socket
@ -121,3 +122,10 @@ def getifhwaddr(device):
struct.pack('256s', bytes(device[:15], 'utf-8'))
)[18:24]
return "%02x:%02x:%02x:%02x:%02x:%02x" % struct.unpack("BBBBBB", hwaddr)
def is_link_local_ipv6(address):
try:
ip = ipaddress.IPv6Address(address)
return ip.is_link_local
except ValueError:
return False