mirror of
https://github.com/BOSWatch/BW3-Core.git
synced 2025-12-06 07:12:04 +01:00
Compare commits
39 commits
848fcde8ee
...
3d8a835aef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d8a835aef | ||
|
|
22f1b7dc29 | ||
|
|
9a3b3336aa | ||
|
|
8ec7aecc2f | ||
|
|
ce3bec23d4 | ||
|
|
71fc7c52c2 | ||
|
|
4415697d31 | ||
|
|
c5015f2160 | ||
|
|
81b93a9f77 | ||
|
|
524efbb0aa | ||
|
|
a6c3395f39 | ||
|
|
a58bc12296 | ||
|
|
23d1b1a328 | ||
|
|
017e882363 | ||
|
|
2e5479cde2 | ||
|
|
16dbd731e8 | ||
|
|
cd21f07755 | ||
|
|
523329a9bb | ||
|
|
6a0a59c3ac | ||
|
|
fe48da9c63 | ||
|
|
04d2a53bcc | ||
|
|
3be1c11100 | ||
|
|
17e761d5cb | ||
|
|
74c8f40afb | ||
|
|
06f0e01649 | ||
|
|
b574c8693f | ||
|
|
f3c2fbe450 | ||
|
|
2d2374df5b | ||
|
|
7c728ce199 | ||
|
|
20da06ab91 | ||
|
|
47c75772d1 | ||
|
|
2862123fe6 | ||
|
|
1836223f95 | ||
|
|
93b84b4876 | ||
|
|
cf33db9c64 | ||
|
|
48648913f8 | ||
|
|
6d3fe8499a | ||
|
|
d8e8662c7d | ||
|
|
ea8fd94926 |
27
.github/workflows/build_image.yml
vendored
Normal file
27
.github/workflows/build_image.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- feature/docker
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build the Docker image for the Client
|
||||
run: docker build . --file Dockerfile --target client --tag ghcr.io/janspeller/bw3-core/client:latest
|
||||
- name: Build the Docker image for the Server
|
||||
run: docker build . --file Dockerfile --target server --tag ghcr.io/janspeller/bw3-core/server:latest
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
- name: Push the Client Docker image to Github package repository
|
||||
run: docker push ghcr.io/janspeller/bw3-core/client:latest
|
||||
- name: Push the Server Docker image to Github package repository
|
||||
run: docker push ghcr.io/janspeller/bw3-core/server:latest
|
||||
2
.github/workflows/run_pytest.yml
vendored
2
.github/workflows/run_pytest.yml
vendored
|
|
@ -1,4 +1,6 @@
|
|||
name: pytest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
|
|||
40
Dockerfile
Normal file → Executable file
40
Dockerfile
Normal file → Executable file
|
|
@ -1,31 +1,45 @@
|
|||
FROM alpine:3.13 AS build-base
|
||||
RUN apk add git make cmake g++ libusb-dev libpulse
|
||||
FROM alpine:latest AS build-base
|
||||
RUN apk add --no-cache git make cmake g++ libusb-dev libpulse
|
||||
|
||||
FROM build-base AS rtl_fm
|
||||
ARG RTL_SDR_VERSION=0.6.0
|
||||
RUN git clone --depth 1 --branch ${RTL_SDR_VERSION} https://github.com/osmocom/rtl-sdr.git /opt/rtl_sdr
|
||||
RUN git clone --depth 1 https://gitea.osmocom.org/sdr/rtl-sdr.git /opt/rtl_sdr
|
||||
WORKDIR /opt/rtl_sdr/build
|
||||
RUN cmake .. && make
|
||||
RUN make install
|
||||
|
||||
FROM build-base AS multimon
|
||||
ARG MULTIMON_VERSION=1.1.9
|
||||
RUN git clone --depth 1 --branch ${MULTIMON_VERSION} https://github.com/EliasOenal/multimon-ng.git /opt/multimon
|
||||
RUN git clone --depth 1 https://github.com/EliasOenal/multimon-ng.git /opt/multimon
|
||||
WORKDIR /opt/multimon/build
|
||||
RUN cmake .. && make
|
||||
|
||||
FROM alpine:3.13 AS boswatch
|
||||
FROM alpine:latest AS boswatch
|
||||
ARG BW_VERSION=develop
|
||||
RUN apk add git && \
|
||||
git clone --depth 1 --branch ${BW_VERSION} https://github.com/BOSWatch/BW3-Core.git /opt/boswatch
|
||||
|
||||
|
||||
FROM python:3.9.1-alpine AS runner
|
||||
LABEL maintainer="bastian@schroll-software.de"
|
||||
FROM python:alpine AS client
|
||||
LABEL org.opencontainers.image.authors="info@schroll-it.de,jan@speller.biz"
|
||||
LABEL org.opencontainers.image.source=https://github.com/janspeller/BW3-Core
|
||||
|
||||
# for RTL for MM
|
||||
RUN apk add libusb-dev libpulse && \
|
||||
pip3 install pyyaml
|
||||
RUN apk add libusb-dev libpulse
|
||||
RUN pip3 install pyyaml
|
||||
|
||||
COPY --from=boswatch /opt/boswatch/ /opt/boswatch/
|
||||
COPY --from=multimon /opt/multimon/build/multimon-ng /opt/multimon-ng
|
||||
COPY --from=rtl_fm /opt/rtl_sdr/build/src/ /opt/rtl_sdr
|
||||
COPY --from=multimon /opt/multimon/build/multimon-ng /opt/multimon/multimon-ng
|
||||
COPY --from=rtl_fm /usr/local/bin/rtl_fm /opt/rtl_sdr/rtl_fm
|
||||
COPY --from=rtl_fm /usr/local/lib/librtlsdr.so.0 /usr/local/lib/librtlsdr.so.0
|
||||
|
||||
WORKDIR /opt/boswatch
|
||||
CMD python3 /opt/boswatch/bw_client.py -c client.yaml
|
||||
|
||||
FROM python:alpine AS server
|
||||
LABEL org.opencontainers.image.authors="info@schroll-it.de,jan@speller.biz"
|
||||
LABEL org.opencontainers.image.source=https://github.com/janspeller/BW3-Core
|
||||
|
||||
RUN pip3 install pyyaml
|
||||
COPY --from=boswatch /opt/boswatch/ /opt/boswatch/
|
||||
WORKDIR /opt/boswatch
|
||||
CMD python3 /opt/boswatch/bw_server.py -c server.yaml
|
||||
EXPOSE 8080
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ r"""!
|
|||
German BOS Information Script
|
||||
by Bastian Schroll
|
||||
|
||||
@file: pocsag.py
|
||||
@date: 06.01.2018
|
||||
@file: pocsagDecoder.py
|
||||
@date: 15.10.2025
|
||||
@author: Bastian Schroll
|
||||
@description: Decoder class for pocsag
|
||||
"""
|
||||
|
|
@ -38,10 +38,15 @@ class PocsagDecoder:
|
|||
@return BOSWatch POCSAG packet or None"""
|
||||
bitrate, ric, subric = PocsagDecoder._getBitrateRicSubric(data)
|
||||
|
||||
if re.search("[0-9]{7}", ric) and re.search("[1-4]", subric):
|
||||
# If no valid SubRIC (Function 0–3) detected → abort
|
||||
if subric is None:
|
||||
logging.warning("Invalid POCSAG function (not 0–3)")
|
||||
return None
|
||||
|
||||
if ric and len(ric) == 7:
|
||||
if "Alpha:" in data:
|
||||
message = data.split('Alpha:')[1].strip()
|
||||
message = message.replace('<NUL>', '').replace('<NUL', '').replace('< NUL>', '').replace('<EOT>', '').strip()
|
||||
message = re.sub(r'<\s*(?:NUL|EOT)\s*>?', '', message).strip()
|
||||
else:
|
||||
message = ""
|
||||
subricText = subric.replace("1", "a").replace("2", "b").replace("3", "c").replace("4", "d")
|
||||
|
|
@ -63,27 +68,27 @@ class PocsagDecoder:
|
|||
|
||||
@staticmethod
|
||||
def _getBitrateRicSubric(data):
|
||||
r"""!Gets the Bitrate, Ric and Subric from data
|
||||
|
||||
@param data: POCSAG data string
|
||||
@return bitrate
|
||||
@return ric
|
||||
@return subric"""
|
||||
bitrate, ric, subric = "0", "0", "0"
|
||||
"""Gets the Bitrate, Ric and Subric from data using robust regex parsing."""
|
||||
bitrate = "0"
|
||||
ric = None
|
||||
subric = None
|
||||
|
||||
# determine bitrate
|
||||
if "POCSAG512:" in data:
|
||||
bitrate = "512"
|
||||
ric = data[20:27].replace(" ", "").zfill(7)
|
||||
subric = str(int(data[39]) + 1)
|
||||
|
||||
elif "POCSAG1200:" in data:
|
||||
bitrate = "1200"
|
||||
ric = data[21:28].replace(" ", "").zfill(7)
|
||||
subric = str(int(data[40]) + 1)
|
||||
|
||||
elif "POCSAG2400:" in data:
|
||||
bitrate = "2400"
|
||||
ric = data[21:28].replace(" ", "").zfill(7)
|
||||
subric = str(int(data[40]) + 1)
|
||||
|
||||
# extract RIC (address)
|
||||
m_ric = re.search(r'Address:\s*(\d{1,7})(?=\s|$)', data)
|
||||
if m_ric:
|
||||
ric = m_ric.group(1).zfill(7)
|
||||
|
||||
# extract SubRIC (function)
|
||||
m_sub = re.search(r'Function:\s*([0-3])', data)
|
||||
if m_sub:
|
||||
subric = str(int(m_sub.group(1)) + 1)
|
||||
|
||||
return bitrate, ric, subric
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ client:
|
|||
sendDelay: 3 # time in seconds to delay the resend try
|
||||
|
||||
server: # only used if useBroadcast = no
|
||||
ip: 127.0.0.1
|
||||
ip: 127.0.0.1 # use service name if you are running docker
|
||||
port: 8080
|
||||
|
||||
inputSource:
|
||||
|
|
@ -27,7 +27,7 @@ inputSource:
|
|||
squelch: 1
|
||||
gain: 100
|
||||
#fir_size: 0
|
||||
rtlPath: /usr/local/bin/rtl_fm
|
||||
rtlPath: /opt/rtl_sdr/rtl_fm
|
||||
lineIn:
|
||||
card: 1
|
||||
device: 0
|
||||
|
|
@ -38,5 +38,5 @@ decoder:
|
|||
poc512: yes
|
||||
poc1200: yes
|
||||
poc2400: yes
|
||||
Path: /opt/multimon/multimon-ng
|
||||
path: /opt/multimon/multimon-ng
|
||||
char: DE
|
||||
|
|
|
|||
13
docker-compose.yaml
Normal file
13
docker-compose.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
version: '3'
|
||||
services:
|
||||
client:
|
||||
image: ghcr.io/janspeller/bw3-core/client:latest
|
||||
volumes:
|
||||
- ./config:/opt/boswatch/config
|
||||
- ./log:/opt/boswatch/log
|
||||
devices:
|
||||
- "/dev/bus/usb"
|
||||
server:
|
||||
image: ghcr.io/janspeller/bw3-core/server:latest
|
||||
volumes:
|
||||
- ./config:/opt/boswatch/config
|
||||
136
docu/docs/install.md
Normal file
136
docu/docs/install.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# 🇩🇪 Anleitung zur Installation von BOSWatch3
|
||||
Die Installation von BOSWatch3 wird mittels diesem bash-Skript weitestgehend automatisiert durchgeführt.
|
||||
## 1. Installationsskript herunterladen
|
||||
Zunächst wird das aktuelle Installationsskript heruntergeladen
|
||||
Öffne ein Terminal und führe folgenden Befehl aus:
|
||||
|
||||
```bash
|
||||
wget https://github.com/BOSWatch/BW3-Core/raw/master/install.sh
|
||||
```
|
||||
|
||||
## 2. Installationsskript ausführen
|
||||
Im Anschluss wird das Skript mit dem Kommando
|
||||
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
ausgeführt.
|
||||
|
||||
### 2a. Optionale Parameter beim Installieren
|
||||
Standardmäßig wird das Programm nach /opt/boswatch3 installiert. Folgende Parameter stehen zur Installation zur Verfügung:
|
||||
|
||||
| Parameter | Zulässige Werte | Beschreibung |
|
||||
| ---------------- | ----------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `-r`, `--reboot` | *(kein Wert notwendig)* | Führt nach der Installation automatisch einen Neustart durch. Ohne Angabe erfolgt **kein** Reboot. |
|
||||
| `-b`, `--branch` | `master`, `develop` | Wählt den zu installierenden Branch. `master` ist stabil (empfohlen), `develop` ist für Entwickler. |
|
||||
| `-p`, `--path` | z. B. `/opt/boswatch3` | Installiert BOSWatch3 in ein anderes Verzeichnis (**nicht empfohlen**). Standard ist `/opt/boswatch3`. |
|
||||
|
||||
**ACHTUNG:**
|
||||
Eine Installation von BOSWatch3 in ein anderes Verzeichnis erfordert viele Anpassungen in den Skripten und erhöht das Risiko, dass das Programm zu Fehlern führt. Es wird dazu geraten, das Standardverzeichnis zu benutzen.
|
||||
|
||||
Falls eine Installation mit Parameter gewünscht wird, so kann dies wie in folgendem Beispiel gestartet werden:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --branch master --path /opt/boswatch3 --reboot
|
||||
```
|
||||
|
||||
## 3. Konfiguration nach der Installation
|
||||
Nach der Installation muss die Konfiguration der Dateien `/opt/boswatch3/config/client.yaml`
|
||||
und `/opt/boswatch3/config/server.yaml` angepasst werden (z.B. mit nano, WinSCP,...):
|
||||
|
||||
```bash
|
||||
sudo nano /opt/boswatch3/config/client.yaml
|
||||
```
|
||||
|
||||
und
|
||||
|
||||
```bash
|
||||
sudo nano /opt/boswatch3/config/server.yaml
|
||||
```
|
||||
|
||||
Passe die Einstellungen nach deinen Anforderungen an. Bei einem Upgrade einer bestehenden Version kann dieser Schritt ggf. entfallen.
|
||||
|
||||
**INFORMATION:**
|
||||
Weitere Informationen zur Konfiguration:
|
||||
[Konfiguration](/docu/docs/config.md)
|
||||
|
||||
## 4. Neustart
|
||||
**WICHTIG:**
|
||||
Bitte starte das System neu, bevor du BOSWatch3 zum ersten Mal startest!
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
## 5. Start von BOSWatch3
|
||||
weiter gehts bei [BOSWatch benutzen](usage.md)
|
||||
|
||||
---
|
||||
|
||||
# 🇬🇧 BOSWatch3 Installation Guide
|
||||
The installation of BOSWatch3 is largely automated using this bash script.
|
||||
|
||||
## 1. Download the Installation Script
|
||||
First, download the latest installation script.
|
||||
Open a terminal and run the following command:
|
||||
|
||||
```bash
|
||||
wget https://github.com/BOSWatch/BW3-Core/raw/master/install.sh
|
||||
```
|
||||
|
||||
## 2. Run the Installation Script
|
||||
Then run the script with the command:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
### 2a. Optional Parameters for Installation
|
||||
By default, the program is installed to `/opt/boswatch3`. The following parameters are available for installation:
|
||||
|
||||
| Parameter | Allowed Values | Description |
|
||||
| ---------------- | -----------------------| --------------------------------------------------------------------------------------------------- |
|
||||
| `-r`, `--reboot` | *(no value needed)* | Automatically reboots the system after installation. Without this, **no** reboot will be performed. |
|
||||
| `-b`, `--branch` | `master`, `develop` | Selects the branch to install. `master` is stable (recommended), `develop` is for developers. |
|
||||
| `-p`, `--path` | e.g. `/opt/boswatch3` | Installs BOSWatch3 to a different directory (**not recommended**). Default is `/opt/boswatch3`. |
|
||||
|
||||
**WARNING:**
|
||||
Installing BOSWatch3 to a different directory requires many adjustments in the scripts and increases the risk of errors. It is recommended to use the default directory.
|
||||
|
||||
If you want to install with parameters, you can run the following example command:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --branch master --path /opt/boswatch3 --reboot
|
||||
```
|
||||
|
||||
## 3. Configuration After Installation
|
||||
After installation, the configuration files `/opt/boswatch3/config/client.yaml`
|
||||
and `/opt/boswatch3/config/server.yaml` must be adjusted (e.g. using nano, WinSCP, ...):
|
||||
|
||||
```bash
|
||||
sudo nano /opt/boswatch3/config/client.yaml
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```bash
|
||||
sudo nano /opt/boswatch3/config/server.yaml
|
||||
```
|
||||
|
||||
Adjust the settings according to your requirements. If upgrading from an existing version, this step might be skipped.
|
||||
|
||||
**INFORMATION:**
|
||||
More information about configuration:
|
||||
[Configuration](/docu/docs/config.md)
|
||||
|
||||
## 4. Reboot
|
||||
**IMPORTANT:**
|
||||
Please reboot the system before starting BOSWatch3 for the first time!
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
## 5. Starting BOSWatch3
|
||||
Continue with [Using BOSWatch](usage.md)
|
||||
|
|
@ -2,16 +2,16 @@
|
|||
---
|
||||
|
||||
## Beschreibung
|
||||
Mit diesem Plugin ist es moeglich, Telegram-Nachrichten für POCSAG-Alarmierungen zu senden.
|
||||
Außerdem werden Locations versendet, wenn die Felder `lat` und `lon` im Paket definiert sind. (beispielsweise durch das [Geocoding](../modul/geocoding.md) Modul)
|
||||
Dieses Plugin ermöglicht das Versenden von Telegram-Nachrichten für verschiedene Alarmierungsarten.
|
||||
Wenn im eingehenden Paket die Felder `lat` und `lon` vorhanden sind (z. B. durch das [Geocoding](../modul/geocoding.md) Modul), wird zusätzlich automatisch der Standort als Telegram-Location gesendet.
|
||||
|
||||
Die abarbeitung der Alarmierungen erfolgt per Queue nach den Limits der Telegram API, damit keine Nachrichten verloren gehen, diese Funktion kann mit dem ```queue``` Parameter deaktiviert werden.
|
||||
Das Senden der Nachrichten erfolgt über eine interne Queue mit Retry-Logik und exponentiellem Backoff, um die Vorgaben der Telegram API einzuhalten und Nachrichtenverluste zu verhindern. Die Retry-Parameter (max_retries, initial_delay, max_delay) können in der Konfiguration angepasst werden.
|
||||
|
||||
## Unterstütze Alarmtypen
|
||||
- Fms
|
||||
- Pocsag
|
||||
- Zvei
|
||||
- Msg
|
||||
- FMS
|
||||
- POCSAG
|
||||
- ZVEI
|
||||
- MSG
|
||||
|
||||
## Resource
|
||||
`telegram`
|
||||
|
|
@ -20,13 +20,17 @@ Die abarbeitung der Alarmierungen erfolgt per Queue nach den Limits der Telegram
|
|||
|
||||
|Feld|Beschreibung|Default|
|
||||
|----|------------|-------|
|
||||
|botToken|Der Api-Key des Telegram-Bots||
|
||||
|chatIds|Liste mit Chat-Ids der Empfängers / der Emfänger-Gruppen||
|
||||
|message_fms|Format der Nachricht für FMS|`{FMS}`|
|
||||
|message_pocsag|Format der Nachricht für Pocsag|`{RIC}({SRIC})\n{MSG}`|
|
||||
|message_zvei|Format der Nachricht für ZVEI|`{TONE}`|
|
||||
|message_msg|Format der Nachricht für MSG||
|
||||
|queue|Aktivieren/Deaktivieren der MessageQueue|true|
|
||||
|botToken|Der Api-Key des Telegram-Bots|-|
|
||||
|chatIds|Liste mit Chat-Ids der Empfängers / der Empfänger-Gruppen|-|
|
||||
|startup_message|Nachricht beim erfolgreichen Initialisieren des Plugins|leer|
|
||||
|message_fms|Formatvorlage für FMS-Alarm|`{FMS}`|
|
||||
|message_pocsag|Formatvorlage für POCSAG|`{RIC}({SRIC})\n{MSG}`|
|
||||
|message_zvei|Formatvorlage für ZVEI|`{TONE}`|
|
||||
|message_msg|Formatvorlage für MSG-Nachricht|-|
|
||||
|max_retries|Anzahl Wiederholungsversuche bei Fehlern|5|
|
||||
|initial_delay|Initiale Wartezeit bei Wiederholungsversuchen|2 [Sek.]|
|
||||
|max_delay|Maximale Retry-Verzögerung|300 [Sek.]|
|
||||
|parse_mode|Formatierung ("HTML" oder "MarkdownV2"), Case-sensitive!|leer|
|
||||
|
||||
**Beispiel:**
|
||||
```yaml
|
||||
|
|
@ -34,19 +38,32 @@ Die abarbeitung der Alarmierungen erfolgt per Queue nach den Limits der Telegram
|
|||
name: Telegram Plugin
|
||||
res: telegram
|
||||
config:
|
||||
message_pocsag: "{RIC}({SRIC})\n{MSG}"
|
||||
message_pocsag: |
|
||||
<b>POCSAG Alarm:</b>
|
||||
RIC: <b>{RIC}</b> ({SRIC})
|
||||
{MSG}
|
||||
parse_mode: "HTML"
|
||||
startup_message: "Server up and running!"
|
||||
botToken: "BOT_TOKEN"
|
||||
chatIds:
|
||||
- "CHAT_ID"
|
||||
```
|
||||
|
||||
Hinweis:
|
||||
Über parse_mode kannst du Telegram-Formatierungen verwenden:
|
||||
|
||||
- HTML: `<b>fett</b>`, `<i>kursiv</i>`, `<u>unterstrichen</u>`, `<s>durchgestrichen</s>`, ...
|
||||
- MarkdownV2: `**fett**`, `__unterstrichen__`, `_italic \*text_` usw. (Escape-Regeln beachten)
|
||||
|
||||
Block-Strings (|) eignen sich perfekt für mehrzeilige Nachrichten und vermeiden Escape-Zeichen wie \n
|
||||
|
||||
---
|
||||
## Modul Abhängigkeiten
|
||||
Aus dem Modul [Geocoding](../modul/geocoding.md) (optional/nur POCSAG):
|
||||
OPTIONAL, nur für POCSAG-Locationversand: Aus dem Modul [Geocoding](../modul/geocoding.md):
|
||||
|
||||
- `lat`
|
||||
- `lon`
|
||||
|
||||
---
|
||||
## Externe Abhängigkeiten
|
||||
- python-telegram-bot
|
||||
keine
|
||||
|
|
|
|||
166
docu/docs/service.md
Normal file
166
docu/docs/service.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# BOSWatch – Dienstinstallation (Service Setup)
|
||||
|
||||
## 🇩🇪 BOSWatch als Dienst verwenden
|
||||
Es wird vorausgesetzt, dass BOSWatch unter `/opt/boswatch3` installiert ist.
|
||||
Falls du einen anderen Installationspfad nutzt, müssen alle Pfadangaben in dieser Anleitung, im Skript sowie in den generierten Service-Dateien entsprechend angepasst werden.
|
||||
|
||||
Für jeden Dienst muss eine eigene `*.yaml`-Datei im Ordner `config/` vorhanden sein.
|
||||
Beim Ausführen des Skripts wirst du interaktiv gefragt, welche dieser YAML-Dateien installiert oder übersprungen werden sollen.
|
||||
|
||||
### Dienst installieren
|
||||
Als Erstes wechseln wir ins BOSWatch Verzeichnis:
|
||||
|
||||
```bash
|
||||
cd /opt/boswatch3
|
||||
```
|
||||
|
||||
Das Installationsskript `install_service.py` wird anschließend mit Root-Rechten ausgeführt:
|
||||
```bash
|
||||
sudo python3 install_service.py
|
||||
```
|
||||
Es folgt ein interaktiver Ablauf, bei dem du gefragt wirst, welche YAML-Dateien installiert oder entfernt werden sollen.
|
||||
|
||||
### Zusätzliche Optionen (fortgeschrittene Anwender)
|
||||
Das Skript bietet zusätzliche CLI-Optionen für mehr Kontrolle:
|
||||
|
||||
```bash
|
||||
usage: install_service.py [-h] [--verbose] [--quiet]
|
||||
|
||||
Installiert oder entfernt systemd-Services für BOSWatch basierend auf YAML-Konfigurationsdateien.
|
||||
|
||||
optional arguments:
|
||||
-h, --help zeigt diese Hilfe an
|
||||
--dry-run für Entwickler: führt keine echten Änderungen aus (Simulation)
|
||||
--verbose zeigt ausführliche Debug-Ausgaben
|
||||
--quiet unterdrückt alle Ausgaben außer Warnungen und Fehlern
|
||||
-l, --lang [de|en] Sprache für alle Ausgaben (Standard: de)
|
||||
```
|
||||
|
||||
### Neustart nach Serviceinstallation
|
||||
Nach Durchlaufen des Skripts boote dein System erneut durch, um den korrekten Startvorgang zu überprüfen:
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
### Kontrolle, ob alles funktioniert hat
|
||||
Um zu kontrollieren, ob alles ordnungsgemäß hochgefahren ist, kannst du die zwei Services mit folgenden Befehlen abfragen und die letzten Log-Einträge ansehen:
|
||||
|
||||
1. Client-Service
|
||||
```bash
|
||||
sudo systemctl status bw3_[clientname].service
|
||||
```
|
||||
|
||||
Ersetze [clientname] mit dem Namen, den deine client.yaml hat (Standardmäßig: client)
|
||||
|
||||
Um das Log zu schließen, "q" drücken.
|
||||
|
||||
2. Server-Service
|
||||
```bash
|
||||
sudo systemctl status bw3_[servername].service
|
||||
```
|
||||
|
||||
Ersetze [servername] mit dem Namen, den deine server.yaml hat (Standardmäßig: server)
|
||||
|
||||
Um das Log zu schließen, "q" drücken.
|
||||
|
||||
**Beide Outputs sollten so ähnlich beginnen:**
|
||||
```text
|
||||
bw3_client.service - BOSWatch Client
|
||||
Loaded: loaded (/etc/systemd/system/bw3_client.service; enabled; preset: enabled)
|
||||
Active: active (running) since Mon 1971-01-01 01:01:01 CEST; 15min 53s ago
|
||||
```
|
||||
|
||||
Falls du in deinen letzten Logzeilen keine Error vorfinden kannst, die auf einen Stopp des Clients bzw. Server hinweisen, läuft das Programm wie gewünscht, sobald du deinen Rechner startest.
|
||||
|
||||
### Logdatei
|
||||
Alle Aktionen des Installationsskripts werden in der Datei log/install/service_install.log protokolliert.
|
||||
|
||||
### Hinweis
|
||||
Nach der Installation oder Entfernung wird systemctl daemon-reexec automatisch aufgerufen, damit systemd die neuen oder entfernten Units korrekt verarbeitet.
|
||||
|
||||
---
|
||||
|
||||
## 🇬🇧 Use BOSWatch as a Service
|
||||
|
||||
We assume that BOSWatch is installed to `/opt/boswatch3`.
|
||||
If you are using a different path, please adjust all paths in this guide, in the script and in the generated service files accordingly.
|
||||
|
||||
Each service requires its own `*.yaml` file inside the `config/` folder.
|
||||
The script will interactively ask which YAML files to install or skip.
|
||||
|
||||
### Install the Service
|
||||
|
||||
First, change directory to BOSWatch folder:
|
||||
|
||||
```bash
|
||||
cd /opt/boswatch3
|
||||
```
|
||||
|
||||
After that, run the install script `install_service.py` with root permissions:
|
||||
|
||||
```bash
|
||||
sudo python3 install_service.py -l en
|
||||
```
|
||||
|
||||
You will be guided through an interactive selection to install or remove desired services.
|
||||
|
||||
### Additional Options
|
||||
|
||||
The script supports additional CLI arguments for advanced usage:
|
||||
|
||||
```bash
|
||||
usage: install_service.py [-h] [--dry-run] [--verbose] [--quiet]
|
||||
|
||||
Installs or removes BOSWatch systemd services based on YAML config files.
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--dry-run simulate actions without making real changes
|
||||
--verbose show detailed debug output
|
||||
--quiet suppress all output except warnings and errors
|
||||
-l, --lang [de|en] Language for all output (default: de)
|
||||
```
|
||||
|
||||
### Reboot After Setup
|
||||
After running the script, reboot your system to verify that the services start correctly:
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
### Verifying Successful Setup
|
||||
To check if everything started properly, you can query the two services and inspect the most recent log entries:
|
||||
|
||||
1. Client Service
|
||||
```bash
|
||||
sudo systemctl status bw3_[clientname].service
|
||||
```
|
||||
|
||||
Replace [clientname] with the name of your client.yaml file (default: client).
|
||||
|
||||
To close the log, press "q".
|
||||
|
||||
2. Server Service
|
||||
```bash
|
||||
sudo systemctl status bw3_[servername].service
|
||||
```
|
||||
|
||||
Replace [servername] with the name of your server.yaml file (default: server).
|
||||
|
||||
To close the log, press "q".
|
||||
|
||||
**Both outputs should start similarly to this:**
|
||||
```text
|
||||
bw3_client.service - BOSWatch Client
|
||||
Loaded: loaded (/etc/systemd/system/bw3_client.service; enabled; preset: enabled)
|
||||
Active: active (running) since Mon 1971-01-01 01:01:01 CEST; 15min 53s ago
|
||||
```
|
||||
|
||||
If the latest log entries do not show any errors indicating a client or server crash, then the services are running correctly and will automatically start on boot.
|
||||
|
||||
### Log File
|
||||
|
||||
All actions of the installation script are logged to `log/install/service_install.log`.
|
||||
|
||||
### Note
|
||||
|
||||
After installation or removal, `systemctl daemon-reexec` is automatically triggered to reload unit files properly.
|
||||
25
docu/docs/usage.md
Normal file
25
docu/docs/usage.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# 🇩🇪 Start von BOSWatch3
|
||||
Nach dem Neustart kannst du BOSWatch3 wie folgt starten:
|
||||
|
||||
```bash
|
||||
cd /opt/boswatch3
|
||||
sudo python3 bw_client.py -c config/client.yaml
|
||||
sudo python3 bw_server.py -c config/server.yaml
|
||||
```
|
||||
|
||||
## Optional: Als Dienst einrichten
|
||||
Weiter gehts mit [als Service einrichten](service.md)
|
||||
|
||||
---
|
||||
|
||||
# 🇬🇧 Starting BOSWatch3
|
||||
After reboot, you can start BOSWatch3 as follows:
|
||||
|
||||
```bash
|
||||
cd /opt/boswatch3
|
||||
sudo python3 bw_client.py -c config/client.yaml
|
||||
sudo python3 bw_server.py -c config/server.yaml
|
||||
```
|
||||
|
||||
## Optional: Setup as a Service
|
||||
For further instructions, see [Setup as a Service](service.md)
|
||||
|
|
@ -7,10 +7,10 @@ edit_uri: edit/develop/docu/docs/
|
|||
nav:
|
||||
# - BW3: index.md
|
||||
- Quick Start:
|
||||
- Installation: tbd.md
|
||||
- Installation: install.md
|
||||
- Konfiguration: config.md
|
||||
# - BOSWatch benutzen: tbd.md
|
||||
# - Als Service einrichten: tbd.md
|
||||
- BOSWatch benutzen: usage.md
|
||||
- Als Service einrichten: service.md
|
||||
- Informationen:
|
||||
- Server/Client Prinzip: information/serverclient.md
|
||||
- Broadcast Service: information/broadcast.md
|
||||
|
|
|
|||
593
install_service.py
Normal file
593
install_service.py
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""!
|
||||
____ ____ ______ __ __ __ _____
|
||||
/ __ )/ __ \/ ___/ | / /___ _/ /______/ /_ |__ /
|
||||
/ __ / / / /\__ \| | /| / / __ `/ __/ ___/ __ \ /_ <
|
||||
/ /_/ / /_/ /___/ /| |/ |/ / /_/ / /_/ /__/ / / / ___/ /
|
||||
/_____/\____//____/ |__/|__/\__,_/\__/\___/_/ /_/ /____/
|
||||
German BOS Information Script
|
||||
by Bastian Schroll
|
||||
|
||||
@file: install_service.py
|
||||
@date: 15.11.2025
|
||||
@author: Claus Schichl
|
||||
@description: Install Service File with argparse CLI
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import logging
|
||||
import argparse
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# === constants for directories and files ===
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
BW_DIR = '/opt/boswatch3'
|
||||
SERVICE_DIR = Path('/etc/systemd/system')
|
||||
CONFIG_DIR = (BASE_DIR / 'config').resolve()
|
||||
LOG_FILE = (BASE_DIR / 'log' / 'install' / 'service_install.log').resolve()
|
||||
os.makedirs(LOG_FILE.parent, exist_ok=True)
|
||||
|
||||
|
||||
# === language management (default german)===
|
||||
_lang = 'de'
|
||||
|
||||
|
||||
def get_lang():
|
||||
return _lang
|
||||
|
||||
|
||||
def set_lang(lang):
|
||||
global _lang
|
||||
_lang = lang
|
||||
|
||||
|
||||
# === text-dictionary ===
|
||||
TEXT = {
|
||||
"de": {
|
||||
"script_title": "🛠️ BOSWatch Service Manager",
|
||||
"mode_dry": "DRY-RUN (nur Vorschau)",
|
||||
"mode_live": "LIVE",
|
||||
"no_yaml": "❌ Keine .yaml-Dateien im config-Verzeichnis gefunden.",
|
||||
"found_yaml": "🔍 Gefundene YAML-Dateien: {}",
|
||||
"action_prompt": "\nWas möchtest du tun? (i=installieren, r=entfernen, e=beenden): ",
|
||||
"edited_prompt": "Wurden die YAML-Dateien korrekt bearbeitet? (y/n): ",
|
||||
"edit_abort": "⚠️ Bitte YAML-Dateien zuerst bearbeiten. Vorgang abgebrochen.",
|
||||
"install_confirm": "Service für '{}' installieren? (y/n): ",
|
||||
"skip_invalid_yaml": "⏭ Überspringe fehlerhafte YAML: {}",
|
||||
"install_done": "✅ Installation abgeschlossen. Services installiert: {}, übersprungen: {}",
|
||||
"invalid_input": "Ungültige Eingabe. Erlaubt sind: {}",
|
||||
"no_services": "Keine bw3-Services gefunden.",
|
||||
"available_services": "\nVerfügbare bw3-Services:",
|
||||
"remove_prompt": "Was soll deinstalliert werden? ",
|
||||
"invalid_choice": "Ungültige Auswahl.",
|
||||
"not_root": "🛑 Dieses Skript muss mit Root-Rechten ausgeführt werden (sudo).",
|
||||
"help_dry_run": "Nur anzeigen, nicht ausführen",
|
||||
"help_verbose": "Ausführliche Ausgabe",
|
||||
"help_quiet": "Weniger Ausgabe",
|
||||
"help_lang": "Sprache für alle Ausgaben [de/en] (Standard: de)",
|
||||
"creating_service_file": "📄 Erstelle Service-Datei für {} → {}",
|
||||
"removing_service": "\n🗑 Entferne Service: {}",
|
||||
"service_deleted": "{} gelöscht.",
|
||||
"service_not_found": "{} nicht gefunden oder im Dry-Run-Modus.",
|
||||
"yaml_error": "⚠ Fehler in YAML {}: {}",
|
||||
"yaml_read_error": "⚠ Fehler beim Lesen der YAML-Datei {}: {}",
|
||||
"unknown_yaml_type": "⚠ YAML-Typ für {} nicht erkannt. Service wird übersprungen.",
|
||||
"verify_warn": "⚠ Warnung bei systemd-analyze verify:\n{}",
|
||||
"verify_ok": "{} erfolgreich verifiziert.",
|
||||
"install_skipped": "⏭ Installation für '{}' übersprungen",
|
||||
"file_write_error": "⚠ Fehler beim Schreiben der Datei {}: {}",
|
||||
"all": "[a] Alle deinstallieren",
|
||||
"exit": "[e] Beenden",
|
||||
"service_active": "✅ Service {0} läuft erfolgreich.",
|
||||
"service_inactive": "⚠ Service {0} ist **nicht aktiv** – bitte prüfen.",
|
||||
"dryrun_status_check": "🧪 [Dry-Run] Service-Status von {0} würde jetzt geprüft.",
|
||||
"max_attempts_exceeded": "❌ Maximale Anzahl an Eingabeversuchen überschritten. Das Menü wird beendet.",
|
||||
"user_interrupt": "\nAbbruch durch Benutzer.",
|
||||
"unhandled_error": "Unbehandelter Fehler: {}",
|
||||
"max_retries_skip": "Maximale Anzahl Eingabeversuche überschritten. Überspringe Service.",
|
||||
"max_retries_exit": "Maximale Anzahl Eingabeversuche überschritten. Beende Programm.",
|
||||
"colorama_missing": "⚠️ Colorama nicht installiert – versuche automatische Installation...",
|
||||
"colorama_install": "➡️ Installiere Colorama...",
|
||||
"colorama_install_ok": "✅ Colorama erfolgreich installiert.",
|
||||
"colorama_install_fail": "❌ Colorama konnte nicht automatisch installiert werden.",
|
||||
"verify_timeout": "⚠ Timeout bei systemd-analyze verify für: {}",
|
||||
"status_timeout": "⚠ Timeout beim Prüfen des Service-Status: {}"
|
||||
|
||||
},
|
||||
"en": {
|
||||
"script_title": "🛠️ BOSWatch Service Manager",
|
||||
"mode_dry": "DRY-RUN (preview only)",
|
||||
"mode_live": "LIVE",
|
||||
"no_yaml": "❌ No .yaml files found in config directory.",
|
||||
"found_yaml": "🔍 YAML files found: {}",
|
||||
"action_prompt": "\nWhat would you like to do? (i=install, r=remove, e=exit): ",
|
||||
"edited_prompt": "Have the YAML files been edited correctly? (y/n): ",
|
||||
"edit_abort": "⚠ Please edit the YAML files first. Aborting.",
|
||||
"install_confirm": "Install service for '{}' ? (y/n): ",
|
||||
"skip_invalid_yaml": "⏭ Skipping invalid YAML: {}",
|
||||
"install_done": "✅ Installation complete. Services installed: {}, skipped: {}",
|
||||
"invalid_input": "Invalid input. Allowed: {}",
|
||||
"no_services": "No bw3 services found.",
|
||||
"available_services": "\nAvailable bw3 services:",
|
||||
"remove_prompt": "What should be removed? ",
|
||||
"invalid_choice": "Invalid choice.",
|
||||
"not_root": "🛑 This script must be run as root (sudo).",
|
||||
"help_dry_run": "Show actions only, do not execute",
|
||||
"help_verbose": "Show detailed output",
|
||||
"help_quiet": "Reduce output verbosity",
|
||||
"help_lang": "Language for all output [de/en] (default: de)",
|
||||
"creating_service_file": "📄 Creating service file for {} → {}",
|
||||
"removing_service": "\n🗑 Removing service: {}",
|
||||
"service_deleted": "{} deleted.",
|
||||
"service_not_found": "{} not found or in dry-run mode.",
|
||||
"yaml_error": "⚠ YAML error in {}: {}",
|
||||
"yaml_read_error": "⚠ Error reading YAML file {}: {}",
|
||||
"unknown_yaml_type": "⚠ Unknown YAML type for {}. Skipping service.",
|
||||
"verify_warn": "⚠ Warning in systemd-analyze verify:\n{}",
|
||||
"verify_ok": "{} verified successfully.",
|
||||
"install_skipped": "⏭ Installation skipped for '{}'",
|
||||
"file_write_error": "⚠ Error writing file {}: {}",
|
||||
"all": "[a] Remove all",
|
||||
"exit": "[e] Exit",
|
||||
"service_active": "✅ Service {0} is running successfully.",
|
||||
"service_inactive": "⚠ Service {0} is **not active** – please check.",
|
||||
"dryrun_status_check": "🧪 [Dry-Run] Service status of {0} would be checked now.",
|
||||
"max_attempts_exceeded": "❌ Maximum number of input attempts exceeded. Exiting menu.",
|
||||
"user_interrupt": "\nInterrupted by user.",
|
||||
"unhandled_error": "Unhandled error: {}",
|
||||
"max_retries_skip": "Maximum input attempts exceeded. Skipping service.",
|
||||
"max_retries_exit": "Maximum input attempts exceeded. Exiting program.",
|
||||
"colorama_missing": "⚠️ Colorama not installed – attempting automatic installation...",
|
||||
"colorama_install": "➡️ Installing Colorama...",
|
||||
"colorama_install_ok": "✅ Colorama installed successfully.",
|
||||
"colorama_install_fail": "❌ Colorama could not be installed automatically.",
|
||||
"verify_timeout": "⚠ Timeout during systemd-analyze verify for: {}",
|
||||
"status_timeout": "⚠ Timeout while checking service status: {}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# === COLORAMA AUTO-INSTALL (dual language) ===
|
||||
def colorama_auto_install():
|
||||
r"""
|
||||
Auto-installs colorama if missing.
|
||||
Note: Language detection happens before colorama is available.
|
||||
"""
|
||||
# recognize language early (before colorama installation)
|
||||
import argparse
|
||||
early_parser = argparse.ArgumentParser(add_help=False)
|
||||
early_parser.add_argument('--lang', '-l', choices=['de', 'en'], default='de')
|
||||
early_args, _ = early_parser.parse_known_args()
|
||||
lang = early_args.lang
|
||||
|
||||
# use text from global TEXT dictionary
|
||||
txt = TEXT[lang]
|
||||
|
||||
try:
|
||||
from colorama import init as colorama_init, Fore, Style
|
||||
colorama_init(autoreset=True)
|
||||
return True, Fore, Style
|
||||
except ImportError:
|
||||
print(txt["colorama_missing"])
|
||||
|
||||
# install Colorama
|
||||
print(txt["colorama_install"])
|
||||
subprocess.run(["sudo", "apt", "install", "-y", "python3-colorama"], check=False)
|
||||
|
||||
# retry importing Colorama
|
||||
try:
|
||||
from colorama import init as colorama_init, Fore, Style
|
||||
colorama_init(autoreset=True)
|
||||
print(txt["colorama_install_ok"])
|
||||
return True, Fore, Style
|
||||
except ImportError:
|
||||
print(txt["colorama_install_fail"])
|
||||
return False, None, None
|
||||
|
||||
|
||||
# === import / install colorama ===
|
||||
colorama_available, Fore, Style = colorama_auto_install()
|
||||
|
||||
if not colorama_available:
|
||||
# provides dummy classes if colorama is not available (no crash)
|
||||
class DummyStyle:
|
||||
RESET_ALL = ""
|
||||
BRIGHT = ""
|
||||
|
||||
class DummyFore:
|
||||
RED = GREEN = YELLOW = BLUE = CYAN = MAGENTA = WHITE = RESET = ""
|
||||
|
||||
Fore = DummyFore()
|
||||
Style = DummyStyle()
|
||||
|
||||
|
||||
# === logging Setup ===
|
||||
def setup_logging(verbose=False, quiet=False):
|
||||
r"""
|
||||
Setup logging to file and console with colorized output.
|
||||
"""
|
||||
log_level = logging.INFO
|
||||
if quiet:
|
||||
log_level = logging.WARNING
|
||||
elif verbose:
|
||||
log_level = logging.DEBUG
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(log_level)
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# File Handler (plain)
|
||||
fh = logging.FileHandler(LOG_FILE)
|
||||
fh.setFormatter(formatter)
|
||||
logger.addHandler(fh)
|
||||
|
||||
# Console Handler (colorized)
|
||||
class ColorFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
logging.DEBUG: Fore.CYAN,
|
||||
logging.INFO: Fore.GREEN,
|
||||
logging.WARNING: Fore.YELLOW,
|
||||
logging.ERROR: Fore.RED,
|
||||
logging.CRITICAL: Fore.RED + Style.BRIGHT,
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
color = self.COLORS.get(record.levelno, Fore.RESET)
|
||||
message = super().format(record)
|
||||
return f"{color}{message}{Style.RESET_ALL}"
|
||||
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setFormatter(ColorFormatter('%(levelname)s: %(message)s'))
|
||||
logger.addHandler(ch)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def t(key):
|
||||
r"""
|
||||
Translation helper: returns the localized string for the given key.
|
||||
"""
|
||||
lang = get_lang()
|
||||
return TEXT.get(lang, TEXT['de']).get(key, key)
|
||||
|
||||
|
||||
def get_user_input(prompt, valid_inputs, max_attempts=3):
|
||||
r"""
|
||||
Prompt user for input until a valid input from valid_inputs is entered or max_attempts exceeded.
|
||||
Raises RuntimeError on failure.
|
||||
"""
|
||||
attempts = 0
|
||||
while attempts < max_attempts:
|
||||
value = input(prompt).strip().lower()
|
||||
if value in valid_inputs:
|
||||
return value
|
||||
logging.warning(t("invalid_input").format(", ".join(valid_inputs)))
|
||||
attempts += 1
|
||||
raise RuntimeError(t("max_attempts_exceeded"))
|
||||
|
||||
|
||||
def list_yaml_files():
|
||||
r"""
|
||||
Returns a list of .yaml or .yml files in the config directory.
|
||||
"""
|
||||
return sorted([f.name for f in CONFIG_DIR.glob("*.y*ml")])
|
||||
|
||||
|
||||
def test_yaml_file(file_path):
|
||||
r"""
|
||||
Tests if YAML file can be loaded without error.
|
||||
"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
yaml.safe_load(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(t("yaml_error").format(file_path, e))
|
||||
return False
|
||||
|
||||
|
||||
def detect_yaml_type(file_path):
|
||||
r"""
|
||||
Detects if YAML config is 'client' or 'server' type.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if 'client' in data:
|
||||
return 'client'
|
||||
elif 'server' in data:
|
||||
return 'server'
|
||||
else:
|
||||
logging.error(t("unknown_yaml_type").format(os.path.basename(file_path)))
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(t("yaml_read_error").format(file_path, e))
|
||||
return None
|
||||
|
||||
|
||||
def execute(command, dry_run=False):
|
||||
r"""
|
||||
Executes shell command unless dry_run is True.
|
||||
"""
|
||||
logging.debug(f"→ {command}")
|
||||
if not dry_run:
|
||||
subprocess.run(command, shell=True, check=False)
|
||||
|
||||
|
||||
def verify_service(service_path):
|
||||
r"""
|
||||
Runs 'systemd-analyze verify' on the service file and logs warnings/errors.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['systemd-analyze', 'verify', service_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode != 0 or result.stderr:
|
||||
logging.warning(t("verify_warn").format(result.stderr.strip()))
|
||||
else:
|
||||
logging.debug(t("verify_ok").format(os.path.basename(service_path)))
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.warning(t("verify_timeout").format(os.path.basename(service_path)))
|
||||
except Exception as e:
|
||||
logging.error(t("yaml_error").format(service_path, e))
|
||||
|
||||
|
||||
def install_service(yaml_file, dry_run=False):
|
||||
r"""
|
||||
Creates and installs systemd service based on YAML config.
|
||||
"""
|
||||
yaml_path = CONFIG_DIR / yaml_file
|
||||
yaml_type = detect_yaml_type(yaml_path)
|
||||
if yaml_type == 'server':
|
||||
is_server = True
|
||||
elif yaml_type == 'client':
|
||||
is_server = False
|
||||
else:
|
||||
logging.error(t("unknown_yaml_type").format(yaml_file))
|
||||
return
|
||||
|
||||
service_name = f"bw3_{Path(yaml_file).stem}.service"
|
||||
service_path = SERVICE_DIR / service_name
|
||||
|
||||
if is_server:
|
||||
exec_line = f"/usr/bin/python3 {BW_DIR}/bw_server.py -c {yaml_file}"
|
||||
description = "BOSWatch Server"
|
||||
after = "network-online.target"
|
||||
wants = "Wants=network-online.target"
|
||||
else:
|
||||
exec_line = f"/usr/bin/python3 {BW_DIR}/bw_client.py -c {yaml_file}"
|
||||
description = "BOSWatch Client"
|
||||
after = "network.target"
|
||||
wants = ""
|
||||
|
||||
service_content = f"""[Unit]
|
||||
Description={description}
|
||||
After={after}
|
||||
{wants}
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={BW_DIR}
|
||||
ExecStart={exec_line}
|
||||
Restart=on-abort
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
logging.info(t("creating_service_file").format(yaml_file, service_name))
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
with open(service_path, 'w', encoding='utf-8') as f:
|
||||
f.write(service_content)
|
||||
except IOError as e:
|
||||
logging.error(t("file_write_error").format(service_path, e))
|
||||
return
|
||||
verify_service(service_path)
|
||||
|
||||
execute("systemctl daemon-reload", dry_run=dry_run)
|
||||
execute(f"systemctl enable {service_name}", dry_run=dry_run)
|
||||
execute(f"systemctl start {service_name}", dry_run=dry_run)
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
subprocess.run(
|
||||
["systemctl", "is-active", "--quiet", service_name],
|
||||
check=True,
|
||||
timeout=5
|
||||
)
|
||||
logging.info(t("service_active").format(service_name))
|
||||
except subprocess.CalledProcessError:
|
||||
logging.warning(t("service_inactive").format(service_name))
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.warning(t("status_timeout").format(service_name))
|
||||
else:
|
||||
logging.info(t("dryrun_status_check").format(service_name))
|
||||
|
||||
|
||||
def remove_service(service_name, dry_run=False):
|
||||
r"""
|
||||
Stops, disables and removes the given systemd service.
|
||||
"""
|
||||
logging.warning(t("removing_service").format(service_name))
|
||||
execute(f"systemctl stop {service_name}", dry_run=dry_run)
|
||||
execute(f"systemctl disable {service_name}", dry_run=dry_run)
|
||||
|
||||
service_path = SERVICE_DIR / service_name
|
||||
if not dry_run and service_path.exists():
|
||||
try:
|
||||
service_path.unlink()
|
||||
logging.info(t("service_deleted").format(service_name))
|
||||
except Exception as e:
|
||||
logging.error(t("file_write_error").format(service_path, e))
|
||||
else:
|
||||
logging.warning(t("service_not_found").format(service_name))
|
||||
|
||||
execute("systemctl daemon-reload", dry_run=dry_run)
|
||||
|
||||
|
||||
def remove_menu(dry_run=False):
|
||||
r"""
|
||||
Interactive menu to remove services.
|
||||
"""
|
||||
while True:
|
||||
services = sorted([
|
||||
f for f in os.listdir(SERVICE_DIR)
|
||||
if f.startswith('bw3_') and f.endswith('.service')
|
||||
])
|
||||
|
||||
if not services:
|
||||
print(Fore.YELLOW + t("no_services") + Style.RESET_ALL)
|
||||
return
|
||||
|
||||
print(Fore.CYAN + "\n" + t("available_services") + Style.RESET_ALL)
|
||||
for i, s in enumerate(services):
|
||||
print(f" [{i}] {s}")
|
||||
print(" " + t("all"))
|
||||
print(" " + t("exit"))
|
||||
|
||||
try:
|
||||
auswahl = get_user_input(
|
||||
t("remove_prompt"),
|
||||
['e', 'a'] + [str(i) for i in range(len(services))]
|
||||
)
|
||||
except RuntimeError:
|
||||
logging.error(t("max_attempts_exceeded"))
|
||||
break
|
||||
|
||||
if auswahl == 'e':
|
||||
break
|
||||
elif auswahl == 'a':
|
||||
for s in services:
|
||||
remove_service(s, dry_run=dry_run)
|
||||
# directly continue to the next loop (updated list!)
|
||||
continue
|
||||
else:
|
||||
remove_service(services[int(auswahl)], dry_run=dry_run)
|
||||
# also directly continue to the next loop (updated list!)
|
||||
continue
|
||||
|
||||
|
||||
def init_language():
|
||||
r"""
|
||||
Parses --lang/-l argument early to set language before other parsing.
|
||||
"""
|
||||
lang_parser = argparse.ArgumentParser(add_help=False)
|
||||
lang_parser.add_argument(
|
||||
'--lang', '-l',
|
||||
choices=['de', 'en'],
|
||||
default='de',
|
||||
metavar='LANG',
|
||||
help=TEXT["en"]["help_lang"]
|
||||
)
|
||||
lang_args, remaining_argv = lang_parser.parse_known_args()
|
||||
set_lang(lang_args.lang)
|
||||
return lang_parser, remaining_argv
|
||||
|
||||
|
||||
def main(dry_run=False):
|
||||
r"""
|
||||
main program: install or remove service.
|
||||
"""
|
||||
print(Fore.GREEN + Style.BRIGHT + t("script_title") + Style.RESET_ALL)
|
||||
print(t('mode_dry') if dry_run else t('mode_live'))
|
||||
print()
|
||||
|
||||
yaml_files = list_yaml_files()
|
||||
if not yaml_files:
|
||||
print(Fore.RED + t("no_yaml") + Style.RESET_ALL)
|
||||
sys.exit(1)
|
||||
|
||||
print(Fore.GREEN + t("found_yaml").format(len(yaml_files)) + Style.RESET_ALL)
|
||||
for f in yaml_files:
|
||||
file_path = CONFIG_DIR / f
|
||||
valid = test_yaml_file(file_path)
|
||||
status = Fore.GREEN + "✅" if valid else Fore.RED + "❌"
|
||||
print(f" - {f} {status}{Style.RESET_ALL}")
|
||||
|
||||
try:
|
||||
action = get_user_input(t("action_prompt"), ['i', 'r', 'e'])
|
||||
except RuntimeError:
|
||||
logging.error(t("max_retries_exit"))
|
||||
sys.exit(1)
|
||||
|
||||
if action == 'e':
|
||||
sys.exit(0)
|
||||
elif action == 'r':
|
||||
remove_menu(dry_run=dry_run)
|
||||
return
|
||||
|
||||
try:
|
||||
edited = get_user_input(t("edited_prompt"), ['y', 'n'])
|
||||
except RuntimeError:
|
||||
logging.error(t("max_retries_exit"))
|
||||
sys.exit(1)
|
||||
|
||||
if edited == 'n':
|
||||
print(Fore.YELLOW + t("edit_abort") + Style.RESET_ALL)
|
||||
sys.exit(0)
|
||||
|
||||
installed = 0
|
||||
skipped = 0
|
||||
|
||||
for yaml_file in yaml_files:
|
||||
file_path = CONFIG_DIR / yaml_file
|
||||
if not test_yaml_file(file_path):
|
||||
print(Fore.RED + t("skip_invalid_yaml").format(yaml_file) + Style.RESET_ALL)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
install = get_user_input(t("install_confirm").format(yaml_file), ['y', 'n'])
|
||||
except RuntimeError:
|
||||
logging.error(t("max_retries_skip"))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if install == 'y':
|
||||
install_service(yaml_file, dry_run=dry_run)
|
||||
installed += 1
|
||||
else:
|
||||
logging.info(t("install_skipped").format(yaml_file))
|
||||
skipped += 1
|
||||
|
||||
print()
|
||||
logging.info(t("install_done").format(installed, skipped))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
lang_parser, remaining_argv = init_language()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=t("script_title"),
|
||||
parents=[lang_parser]
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true', help=t("help_dry_run"))
|
||||
parser.add_argument('--verbose', action='store_true', help=t("help_verbose"))
|
||||
parser.add_argument('--quiet', action='store_true', help=t("help_quiet"))
|
||||
|
||||
args = parser.parse_args(remaining_argv)
|
||||
|
||||
setup_logging(verbose=args.verbose, quiet=args.quiet)
|
||||
|
||||
if os.geteuid() != 0:
|
||||
print(Fore.RED + t("not_root") + Style.RESET_ALL)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
main(dry_run=args.dry_run)
|
||||
except KeyboardInterrupt:
|
||||
print(t("user_interrupt"))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logging.critical(t("unhandled_error").format(e))
|
||||
sys.exit(1)
|
||||
|
|
@ -57,8 +57,11 @@ class BoswatchPlugin(PluginBase):
|
|||
if self.cursor.fetchone() is None:
|
||||
with open('init_db.sql') as f:
|
||||
for stmnt in f.read().split(';'):
|
||||
self.cursor.execute(stmnt)
|
||||
self.connection.commit()
|
||||
# Change: Remove whitespace and check for empty strings
|
||||
clean_stmnt = stmnt.strip()
|
||||
if clean_stmnt: # only if the string is not empty
|
||||
self.cursor.execute(clean_stmnt)
|
||||
self.connection.commit()
|
||||
|
||||
self.cursor.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,126 +10,207 @@ r"""!
|
|||
by Bastian Schroll
|
||||
|
||||
@file: telegram.py
|
||||
@date: 20.02.2020
|
||||
@author: Jan Speller
|
||||
@description: Telegram Plugin
|
||||
@date: 17.11.2025
|
||||
@author: Claus Schichl nach der Idee von Jan Speller
|
||||
@description: Telegram-Plugin mit Retry-Logik ohne externe Telegram-Abhängigkeiten
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
import queue
|
||||
import requests
|
||||
from plugin.pluginBase import PluginBase
|
||||
|
||||
# ###################### #
|
||||
# Custom plugin includes #
|
||||
from telegram.error import (TelegramError, Unauthorized, BadRequest, TimedOut, NetworkError)
|
||||
from telegram.ext import messagequeue as mq
|
||||
from telegram.utils.request import Request
|
||||
import telegram.bot
|
||||
# ###################### #
|
||||
|
||||
logging.debug("- %s loaded", __name__)
|
||||
# Setup Logging
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
level=logging.INFO
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MQBot(telegram.bot.Bot):
|
||||
'''A subclass of Bot which delegates send method handling to MQ'''
|
||||
# ===========================
|
||||
# TelegramSender-Class
|
||||
# ===========================
|
||||
|
||||
def __init__(self, *args, is_queued_def=True, mqueue=None, **kwargs):
|
||||
super(MQBot, self).__init__(*args, **kwargs)
|
||||
# below 2 attributes should be provided for decorator usage
|
||||
self._is_messages_queued_default = is_queued_def
|
||||
self._msg_queue = mqueue or mq.MessageQueue()
|
||||
class TelegramSender:
|
||||
def __init__(self, bot_token, chat_ids, max_retries=None, initial_delay=None, max_delay=None, parse_mode=None):
|
||||
self.bot_token = bot_token
|
||||
self.chat_ids = chat_ids
|
||||
self.max_retries = max_retries if max_retries is not None else 5
|
||||
self.initial_delay = initial_delay if initial_delay is not None else 2
|
||||
self.max_delay = max_delay if max_delay is not None else 300
|
||||
self.parse_mode = parse_mode
|
||||
self.msg_queue = queue.Queue()
|
||||
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
def send_message(self, text):
|
||||
for chat_id in self.chat_ids:
|
||||
self.msg_queue.put(("text", chat_id, text, 0)) # retry_count = 0
|
||||
|
||||
def send_location(self, latitude, longitude):
|
||||
for chat_id in self.chat_ids:
|
||||
self.msg_queue.put(("location", chat_id, {"latitude": latitude, "longitude": longitude}, 0))
|
||||
|
||||
def _worker_loop(self):
|
||||
delay = self.initial_delay
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg_type, chat_id, content, retry_count = self.msg_queue.get()
|
||||
|
||||
success, permanent_failure, custom_delay = self._send_to_telegram(msg_type, chat_id, content)
|
||||
|
||||
if success:
|
||||
delay = self.initial_delay
|
||||
|
||||
elif permanent_failure:
|
||||
logger.error("Permanenter Fehler – Nachricht wird verworfen.")
|
||||
|
||||
elif retry_count >= self.max_retries:
|
||||
logger.error("Maximale Wiederholungsanzahl erreicht – Nachricht wird verworfen.")
|
||||
|
||||
else:
|
||||
logger.warning(f"Erneutes Einreihen der Nachricht (Versuch {retry_count + 1}).")
|
||||
self.msg_queue.put((msg_type, chat_id, content, retry_count + 1))
|
||||
|
||||
# use the Telegram-provided value (retry_after) if available
|
||||
wait_time = custom_delay if custom_delay is not None else delay
|
||||
time.sleep(wait_time)
|
||||
|
||||
# increase delay for the next attempt (exponential backoff)
|
||||
delay = min(delay * 2, self.max_delay)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Fehler im Telegram-Worker: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _send_to_telegram(self, msg_type, chat_id, content):
|
||||
if msg_type == "text":
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
payload = {
|
||||
'chat_id': chat_id,
|
||||
'text': content,
|
||||
}
|
||||
if self.parse_mode:
|
||||
payload['parse_mode'] = self.parse_mode
|
||||
elif msg_type == "location":
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendLocation"
|
||||
payload = {
|
||||
'chat_id': chat_id,
|
||||
**content
|
||||
}
|
||||
else:
|
||||
logger.error("Unbekannter Nachrichtentyp.")
|
||||
return False, True, None # unknown message type = permanent failure
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self._msg_queue.stop()
|
||||
except:
|
||||
pass
|
||||
custom_delay = None # standardvalue for return, except in case of 429
|
||||
|
||||
@mq.queuedmessage
|
||||
def send_message(self, *args, **kwargs):
|
||||
'''Wrapped method would accept new `queued` and `isgroup`
|
||||
OPTIONAL arguments'''
|
||||
return super(MQBot, self).send_message(*args, **kwargs)
|
||||
response = requests.post(url, data=payload, timeout=10)
|
||||
|
||||
if response.status_code == 429:
|
||||
custom_delay = response.json().get("parameters", {}).get("retry_after", 5)
|
||||
logger.warning(f"Rate Limit erreicht – warte {custom_delay} Sekunden.")
|
||||
return False, False, custom_delay # Telegram gives exact wait time
|
||||
|
||||
if response.status_code == 400:
|
||||
logger.error("Ungültige Parameter – Nachricht wird nicht erneut gesendet.")
|
||||
return False, True, custom_delay # permanent failure
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.critical("Ungültiger Bot-Token – bitte prüfen!")
|
||||
return False, True, custom_delay # permanent failure
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Erfolgreich gesendet an Chat-ID {chat_id}")
|
||||
return True, False, custom_delay
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Fehler beim Senden an Telegram (Chat-ID {chat_id}): {e}")
|
||||
return False, False, custom_delay
|
||||
|
||||
|
||||
# ===========================
|
||||
# BoswatchPlugin-Class
|
||||
# ===========================
|
||||
|
||||
|
||||
class BoswatchPlugin(PluginBase):
|
||||
r"""!Description of the Plugin"""
|
||||
|
||||
def __init__(self, config):
|
||||
r"""!Do not change anything here!"""
|
||||
super().__init__(__name__, config) # you can access the config class on 'self.config'
|
||||
|
||||
def onLoad(self):
|
||||
r"""!Called by import of the plugin"""
|
||||
if self.config.get("queue", default=True):
|
||||
q = mq.MessageQueue()
|
||||
request = Request(con_pool_size=8)
|
||||
self.bot = MQBot(token=self.config.get("botToken", default=""), request=request, mqueue=q)
|
||||
print('queue')
|
||||
else:
|
||||
self.bot = telegram.Bot(token=self.config.get("botToken"))
|
||||
print('normal')
|
||||
bot_token = self.config.get("botToken")
|
||||
chat_ids = self.config.get("chatIds", default=[])
|
||||
|
||||
if not bot_token or not chat_ids:
|
||||
logger.error("botToken oder chatIds fehlen in der Konfiguration!")
|
||||
return
|
||||
|
||||
# configurable parameters with fallback defaults
|
||||
max_retries = self.config.get("max_retries")
|
||||
initial_delay = self.config.get("initial_delay")
|
||||
max_delay = self.config.get("max_delay")
|
||||
parse_mode = self.config.get("parse_mode")
|
||||
|
||||
self.sender = TelegramSender(
|
||||
bot_token=bot_token,
|
||||
chat_ids=chat_ids,
|
||||
max_retries=max_retries,
|
||||
initial_delay=initial_delay,
|
||||
max_delay=max_delay,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
startup_message = self.config.get("startup_message")
|
||||
if startup_message and startup_message.strip():
|
||||
self.sender.send_message(startup_message)
|
||||
|
||||
def setup(self):
|
||||
r"""!Called before alarm
|
||||
Remove if not implemented"""
|
||||
pass
|
||||
|
||||
def fms(self, bwPacket):
|
||||
r"""!Called on FMS alarm
|
||||
|
||||
@param bwPacket: bwPacket instance"""
|
||||
msg = self.parseWildcards(self.config.get("message_fms", default="{FMS}"))
|
||||
self._sendMessage(msg)
|
||||
self.sender.send_message(msg)
|
||||
|
||||
def pocsag(self, bwPacket):
|
||||
r"""!Called on POCSAG alarm
|
||||
|
||||
@param bwPacket: bwPacket instance"""
|
||||
msg = self.parseWildcards(self.config.get("message_pocsag", default="{RIC}({SRIC})\n{MSG}"))
|
||||
self._sendMessage(msg)
|
||||
self.sender.send_message(msg)
|
||||
|
||||
if bwPacket.get("lat") is not None and bwPacket.get("lon") is not None:
|
||||
logging.debug("Found coordinates in packet")
|
||||
(lat, lon) = (bwPacket.get("lat"), bwPacket.get("lon"))
|
||||
self._sendLocation(lat, lon)
|
||||
lat, lon = bwPacket.get("lat"), bwPacket.get("lon")
|
||||
logger.debug("Koordinaten gefunden – sende Standort.")
|
||||
self.sender.send_location(lat, lon)
|
||||
|
||||
def zvei(self, bwPacket):
|
||||
r"""!Called on ZVEI alarm
|
||||
|
||||
@param bwPacket: bwPacket instance"""
|
||||
msg = self.parseWildcards(self.config.get("message_zvei", default="{TONE}"))
|
||||
self._sendMessage(msg)
|
||||
self.sender.send_message(msg)
|
||||
|
||||
def msg(self, bwPacket):
|
||||
r"""!Called on MSG packet
|
||||
|
||||
@param bwPacket: bwPacket instance"""
|
||||
msg = self.parseWildcards(self.config.get("message_msg"))
|
||||
self._sendMessage(msg)
|
||||
self.sender.send_message(msg)
|
||||
|
||||
def _sendMessage(self, message):
|
||||
for chatId in self.config.get("chatIds", default=[]):
|
||||
try:
|
||||
# Send Message via Telegram
|
||||
logging.info("Sending message to " + chatId)
|
||||
self.bot.send_message(chat_id=chatId, text=message)
|
||||
def teardown(self):
|
||||
r"""!Called after alarm
|
||||
Remove if not implemented"""
|
||||
pass
|
||||
|
||||
except Unauthorized:
|
||||
logging.exception("Error while sending Telegram Message, please Check your api-key")
|
||||
except (TimedOut, NetworkError):
|
||||
logging.exception("Error while sending Telegram Message, please Check your connectivity")
|
||||
except (BadRequest, TelegramError):
|
||||
logging.exception("Error while sending Telegram Message")
|
||||
except Exception as e:
|
||||
logging.exception("Unknown Error while sending Telegram Message: " + str(type(e).__name__) + ": " + str(e))
|
||||
|
||||
def _sendLocation(self, lat, lon):
|
||||
for chatId in self.config.get("chatIds", default=[]):
|
||||
try:
|
||||
# Send Location via Telegram
|
||||
if lat is not None and lon is not None:
|
||||
logging.info("Sending location to " + chatId)
|
||||
self.bot.sendLocation(chat_id=chatId, latitude=lat, longitude=lon)
|
||||
|
||||
except Unauthorized:
|
||||
logging.exception("Error while sending Telegram Message, please Check your api-key")
|
||||
except (TimedOut, NetworkError):
|
||||
logging.exception("Error while sending Telegram Message, please Check your connectivity")
|
||||
except (BadRequest, TelegramError):
|
||||
logging.exception("Error while sending Telegram Message")
|
||||
except Exception as e:
|
||||
logging.exception("Unknown Error while sending Telegram Message: " + str(type(e).__name__) + ": " + str(e))
|
||||
def onUnload(self):
|
||||
r"""!Called by destruction of the plugin
|
||||
Remove if not implemented"""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ pyyaml
|
|||
# for documentation generating
|
||||
mkdocs
|
||||
|
||||
# for develope only
|
||||
# for develop only
|
||||
pytest
|
||||
pytest-cov
|
||||
flake8==6.1.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue