mirror of
https://github.com/adamzend/Uploading-server-data-meteo-to-Lemmy-instance.git
synced 2026-10-30 19:02:00 +01:00
Release v1.0.0: configurable Modbus weather station publisher for Lemmy
This commit is contained in:
commit
4153cd7361
21 files changed
+1632
No files matched your search
+17
@@ -0,0 +1,17 @@
|
||||
config.json
|
||||
static.txt
|
||||
.venv/
|
||||
.cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
logs/
|
||||
wykresy/
|
||||
tmp/
|
||||
*.sqlite3*
|
||||
*.log
|
||||
*.bak
|
||||
*.tar.gz
|
||||
crontab*.txt
|
||||
secrets.json
|
||||
*token*.json
|
||||
@@ -0,0 +1,85 @@
|
||||
OPIS CONFIG.JSON
|
||||
|
||||
Utwórz config.json przez install.sh lub skopiuj config.example.json.
|
||||
Plik jest JSON: klucze i tekst w podwójnych cudzysłowach, kropka dziesiętna,
|
||||
true/false małymi literami, null dla nieustawionego ID. Bez komentarzy
|
||||
wewnątrz JSON i bez przecinka po ostatnim polu.
|
||||
|
||||
timezone
|
||||
Strefa IANA, domyślnie UTC. Wpisz strefę swojej stacji. Steruje opisami dat
|
||||
i wyborem miesiąca, nie zmienia strefy systemowego CRON-a.
|
||||
stale_after_minutes
|
||||
Domyślnie 90. Maksymalny wiek odczytu, by przy publikacji uznać go za świeży.
|
||||
Także granica odstępu między punktami, po której wykres przerywa linię.
|
||||
Dobierz do harmonogramu: dla pomiaru co godzinę 90 daje zapas 30 minut.
|
||||
|
||||
modbus.host
|
||||
Adres IP lub nazwa urządzenia dostępnego z serwera. W szablonie pusty:
|
||||
musisz go uzupełnić. Sam host, bez przedrostka protokołu i bez portu.
|
||||
modbus.port
|
||||
Port TCP urządzenia, domyślnie standardowe 502; dopuszczalne 1..65535.
|
||||
modbus.unit_id
|
||||
ID urządzenia za bramką, 0..255. Wpisz wartość z dokumentacji urządzenia.
|
||||
modbus.function_code
|
||||
3 = Holding Registers (FC03), 4 = Input Registers (FC04).
|
||||
Jeden typ odczytu obowiązuje wszystkie trzy czujniki.
|
||||
modbus.timeout_seconds
|
||||
Limit oczekiwania na połączenie/operację odczytu; domyślnie 5.
|
||||
modbus.attempts
|
||||
Maksymalna liczba prób całego zestawu pomiarów, 1..10, domyślnie 3.
|
||||
modbus.retry_delay_seconds
|
||||
Przerwa między nieudanymi próbami, domyślnie 2 sekundy.
|
||||
|
||||
sensors.temperature / sensors.humidity / sensors.pressure
|
||||
Każda z trzech sekcji ma WŁASNE pola:
|
||||
address: adres rejestru przesyłany w protokole, 0..65535. null trzeba
|
||||
zastąpić liczbą. Program nie odejmuje automatycznie 1 ani 40001.
|
||||
Oznaczenia producenta typu 40001/LW nie są uniwersalne; sprawdź,
|
||||
czy instrukcja urządzenia stosuje adresy zerowe czy numerację od 1.
|
||||
signed: true dla int16 ze znakiem; false dla uint16. Dla temperatury
|
||||
ujemnej najczęściej potrzebne true. Zależy od urządzenia.
|
||||
multiplier: osobny mnożnik. Przykład: 0.1 daje dzielenie przez 10.
|
||||
offset: korekta dodawana po przemnożeniu, zwykle 0.
|
||||
min / max: dopuszczalny zakres już PRZELICZONEGO wyniku. Poza zakresem
|
||||
oznacza błąd całej próby, a nie automatyczne ograniczenie wartości.
|
||||
|
||||
Wzór: wynik = wartość_po_dekodowaniu_znaku * multiplier + offset.
|
||||
Przykłady:
|
||||
234 * 0.1 + 0 = 23.4 -> we wpisie „23,4 °C”.
|
||||
65481 przy signed=true -> -55 -> * 0.1 = -5.5 °C.
|
||||
456 * 0.1 = 45.6 %.
|
||||
10132 * 0.1 = 1013.2 hPa.
|
||||
Temperatura jest prezentowana w °C, wilgotność w %, ciśnienie w hPa.
|
||||
Jeśli urządzenie daje inne jednostki, dobierz multiplier i offset, aby
|
||||
wynik był właśnie w tych jednostkach. Nie zmieniaj tylko etykiety.
|
||||
Nie ma obsługi dodatkowych czujników ani dowolnej listy parametrów.
|
||||
|
||||
lemmy.enabled
|
||||
false: bez publikowania; true: włącz API po uzupełnieniu wymaganych pól.
|
||||
lemmy.instance
|
||||
Pełny bazowy adres HTTPS własnej instancji, bez /post/... lub /api/v3,
|
||||
bez loginu/hasła w URL. Użyj adresu docelowego bez przekierowań HTTP.
|
||||
lemmy.username / lemmy.password
|
||||
Login (lub e-mail) i hasło konta autora Lemmy. To nie są dane do systemu,
|
||||
Modbus, bazy PostgreSQL ani SMTP. Hasło jest lokalnie w config.json;
|
||||
dostęp ogranicza chmod 600. To plik tajny, nie zaszyfrowany sejf.
|
||||
lemmy.community_id
|
||||
Dodatni liczbowy ID społeczności na tej instancji, nie jej nazwa.
|
||||
lemmy.post_id
|
||||
null: wyszukaj/utwórz wpis. Dodatnie ID: aktualizuj wskazany własny wpis.
|
||||
Stan z data/lemmy-state.json ma utrzymać ciągłość; sprzeczne ID powoduje błąd.
|
||||
lemmy.title
|
||||
Tytuł nowego wpisu i kryterium rozpoznawania po niepewnym utworzeniu.
|
||||
lemmy.heading
|
||||
Nagłówek wyświetlany nad temperaturą w treści wpisu.
|
||||
lemmy.pin_in_community
|
||||
true: próbuj przypiąć wpis w społeczności. Konto musi mieć uprawnienia.
|
||||
false nie odpina wpisu już przypiętego; wyłącza wymuszanie przypięcia.
|
||||
lemmy.update_note
|
||||
Tekst pod wartościami, np. opis częstotliwości. Sam nie zmienia CRON-a.
|
||||
|
||||
PAMIĘTAJ
|
||||
Po edycji: .venv/bin/python settings.py. Nie wyświetla haseł.
|
||||
Zmiany wczytywane są podczas kolejnego uruchomienia; usługa stale działająca
|
||||
nie jest potrzebna, a serwera nie trzeba restartować.
|
||||
W razie niepewności sprawdź jeden odczyt z lemmy.enabled=false.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Uploading server data (meteo) to Lemmy instance
|
||||
|
||||
A Python application that reads a physical weather station over **Modbus TCP** and publishes its measurements to a **single, regularly updated Lemmy post**.
|
||||
|
||||
It collects temperature, relative humidity and atmospheric pressure, stores the history in SQLite, generates three monthly charts, and uploads the results through Lemmy's API. A standard Linux CRON job runs the complete cycle every hour.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable Modbus host, TCP port, device ID and register addresses.
|
||||
- Holding registers (FC03) or input registers (FC04), with signed or unsigned 16-bit values.
|
||||
- A separate **multiplier and offset for each measurement**.
|
||||
- Validation, retries and a short station status: working correctly or station error.
|
||||
- SQLite history, including failed readings without replacing them with zeroes.
|
||||
- Three monthly PNG charts: temperature, humidity and pressure.
|
||||
- Updates to one existing post, preserving the static text outside the managed section.
|
||||
- Optional community pinning when the publishing account has moderator permissions.
|
||||
- User-level CRON scheduling, isolated Python dependencies and file locks against overlapping cycles.
|
||||
|
||||
## How it works
|
||||
|
||||
```text
|
||||
Modbus TCP device
|
||||
|
|
||||
v
|
||||
Read registers -> apply scaling -> validate -> SQLite + latest status
|
||||
|
|
||||
v
|
||||
Three monthly charts
|
||||
|
|
||||
v
|
||||
Upload images and update one Lemmy post
|
||||
```
|
||||
|
||||
Each value is calculated on the collection server:
|
||||
|
||||
```text
|
||||
measurement = decoded_register * multiplier + offset
|
||||
```
|
||||
|
||||
For example, a register value of `234` with a multiplier of `0.1` becomes `23.4 °C`. Lemmy receives formatted Markdown text and uploaded PNG images, not raw Modbus registers. The current presentation uses Polish labels and decimal commas.
|
||||
|
||||
The charts cover the current calendar month in the configured time zone. Missing readings break the plotted lines. Old measurements remain in the database when a new month begins.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with Python 3.11 or later; validated on Debian 12 with Python 3.11.2.
|
||||
- Network access to the Modbus device and HTTPS access to the Lemmy instance.
|
||||
- An active Lemmy account allowed to post in the selected community and upload images.
|
||||
- **Lemmy API v3**, based on the working Lemmy 0.19.11 deployment. API v4 is not implemented.
|
||||
- Moderator or administrator permissions only if community pinning is enabled.
|
||||
|
||||
The application supports one 16-bit register per measurement. Multi-register floats and custom word order are not implemented. The three registers are read separately, so they do not need to be adjacent; the readings are not a single atomic hardware sample. The program performs no Modbus writes.
|
||||
|
||||
No Docker container or incoming web server is required on the collection machine. Installation does not require rebooting the server.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Download the application
|
||||
|
||||
Download and extract the package from [Releases](../../releases), or clone this repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/adamzend/Uploading-server-data-meteo-to-Lemmy-instance.git meteo
|
||||
cd meteo
|
||||
```
|
||||
|
||||
### 2. Install the system dependencies and Python environment
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install python3 python3-venv cron ca-certificates mc
|
||||
sh install.sh
|
||||
```
|
||||
|
||||
Run `install.sh` as the ordinary Linux user who will run the station. It creates `.venv`, installs the pinned dependencies and copies `config.example.json` to `config.json` if needed. It does **not** enable CRON or publish anything.
|
||||
|
||||
### 3. Configure and test the measurements
|
||||
|
||||
```bash
|
||||
mcedit config.json
|
||||
chmod 600 config.json
|
||||
.venv/bin/python settings.py
|
||||
.venv/bin/python -m unittest discover -v
|
||||
.venv/bin/python meteo.py
|
||||
.venv/bin/python charts.py
|
||||
```
|
||||
|
||||
Fill in the Modbus host, port, device ID, function code and all three register addresses. Choose the correct signedness and multiplier for each sensor. Leave `lemmy.enabled` set to `false` while testing.
|
||||
|
||||
`settings.py` validates configuration without connecting to external services. The test suite uses simulated data and API responses. `meteo.py` performs a real reading and adds it to the database. Check `data/status.json` and the three images in `wykresy/`.
|
||||
|
||||
### 4. Configure Lemmy
|
||||
|
||||
In `config.json`, enter the instance's canonical HTTPS origin, the publishing account's username and password, and the numeric community ID. Set the post title and heading.
|
||||
|
||||
- Leave `post_id` as `null` to find or create the weather post.
|
||||
- Set `post_id` to update an existing post owned by this account in this community.
|
||||
- Enable `pin_in_community` only when the account has the required permissions.
|
||||
- The program authenticates through the API and caches a JWT locally. It does not create accounts or grant permissions. TOTP login is not implemented.
|
||||
|
||||
For a new post, you can prepare a static description of your sensors:
|
||||
|
||||
```bash
|
||||
cp static.example.txt static.txt
|
||||
mcedit static.txt
|
||||
```
|
||||
|
||||
The content of `static.txt` is appended **only when a new post is created**. Edit the static section directly in Lemmy afterward.
|
||||
|
||||
For an existing post, place these markers on separate lines with blank lines around the managed content:
|
||||
|
||||
```markdown
|
||||
[meteo-start]: #
|
||||
|
||||
Measurements and charts will be inserted here.
|
||||
|
||||
[meteo-end]: #
|
||||
|
||||
Your permanent sensor description goes here.
|
||||
```
|
||||
|
||||
The program replaces only the section between the markers. Missing, duplicated or reversed markers cause publication to stop instead of replacing the entire post.
|
||||
|
||||
Set `lemmy.enabled` to `true`, then run the first complete cycle:
|
||||
|
||||
```bash
|
||||
.venv/bin/python settings.py
|
||||
.venv/bin/python run_cycle.py
|
||||
```
|
||||
|
||||
Check the resulting post. Its URL is recorded in `logs/meteo.log`, and its ID is saved in `data/lemmy-state.json`.
|
||||
|
||||
### 5. Enable hourly updates
|
||||
|
||||
```bash
|
||||
.venv/bin/python install_cron.py
|
||||
crontab -l
|
||||
systemctl is-active cron
|
||||
```
|
||||
|
||||
If CRON is disabled:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now cron
|
||||
```
|
||||
|
||||
The installer schedules a cycle at the beginning of every hour, preserves unrelated jobs and saves a backup of the previous crontab. It manages one `# meteo-collector` job per Linux user. The schedule follows the server's CRON time zone; chart dates use `timezone` from the application configuration.
|
||||
|
||||
To edit the schedule or remove this job:
|
||||
|
||||
```bash
|
||||
EDITOR=mcedit crontab -e
|
||||
.venv/bin/python install_cron.py --remove
|
||||
```
|
||||
|
||||
When changing the interval, also adjust `stale_after_minutes` and `lemmy.update_note`. Running the installer again restores the hourly schedule.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
The complete template is [config.example.json](config.example.json).
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| `timezone` | IANA time zone for dates and month boundaries; default `UTC`. |
|
||||
| `stale_after_minutes` | Maximum acceptable reading age at publication, also the chart gap threshold; default 90. |
|
||||
| `modbus.host`, `port`, `unit_id` | Device network address, TCP port and Modbus unit ID. |
|
||||
| `modbus.function_code` | `3` for holding registers or `4` for input registers. |
|
||||
| `modbus.timeout_seconds`, `attempts`, `retry_delay_seconds` | Timeout and retry controls. |
|
||||
| `sensors.<name>.address` | Protocol register address, from 0 to 65535. All three must be filled in. |
|
||||
| `sensors.<name>.signed` | `true` for int16, `false` for uint16. |
|
||||
| `sensors.<name>.multiplier`, `offset` | Per-sensor conversion applied after decoding the sign. |
|
||||
| `sensors.<name>.min`, `max` | Valid range of the converted measurement. |
|
||||
| `lemmy.enabled` | Enables publishing; disabled in the template. |
|
||||
| `lemmy.instance`, `username`, `password` | Instance origin and publishing account credentials. |
|
||||
| `lemmy.community_id`, `post_id` | Local community ID and optional existing post ID. |
|
||||
| `lemmy.title`, `heading`, `update_note` | New post title, in-post heading and update description. |
|
||||
| `lemmy.pin_in_community` | Requests community pinning; does not grant moderation rights. |
|
||||
|
||||
Sensor names are `temperature`, `humidity` and `pressure`. Converted values must be in **°C, %, and hPa**, respectively. Addresses are sent as configured: the application does not automatically subtract 1 or 40001 from a manufacturer's register numbering.
|
||||
|
||||
Detailed Polish documentation is also included: [installation and operation](README.txt) and [configuration](KONFIGURACJA.txt).
|
||||
|
||||
## Operation and data
|
||||
|
||||
| File or directory | Contents |
|
||||
| --- | --- |
|
||||
| `run_cycle.py` | Measurement, chart generation and publication in order. |
|
||||
| `meteo.py` | Modbus reads, conversion, validation and storage. |
|
||||
| `charts.py` | Monthly charts and their manifest. |
|
||||
| `publish_lemmy.py` | Authentication, image upload and post updates. |
|
||||
| `data/` | SQLite history, latest status, JWT, post state and upload history. |
|
||||
| `wykresy/` | Three PNG charts and their manifest. |
|
||||
| `logs/` | Application and CRON logs. |
|
||||
| `.venv/`, `.cache/` | Local Python environment and plotting cache. |
|
||||
|
||||
A failed reading is stored as an error. The post can show the last successful values with their timestamp and an error status. If the entire server, network connection or Lemmy API becomes unavailable, the post cannot update itself: check the last measurement time. This status is not an independent external uptime monitor.
|
||||
|
||||
Old uploaded chart images are not automatically deleted from pict-rs. Application logs rotate, but `logs/cron.log` does not. Plan storage and backups accordingly.
|
||||
|
||||
To migrate, disable the old CRON job and let its current cycle finish, then copy the application, your private configuration and the complete `data/` directory. Recreate the environment with `sh install.sh`, test one cycle and enable CRON on the new server. Do not run two publishers against the same post. For a live SQLite backup, use the SQLite backup API instead of copying only the database file while WAL writes are active.
|
||||
|
||||
## Credentials and verification
|
||||
|
||||
`config.json` contains your password in plain text and must remain private. It and the generated data, logs and tokens are excluded by `.gitignore`. Do not publish them. The public template has no device address, account credentials or deployment-specific register map.
|
||||
|
||||
The first release passed **18 automated tests on Debian 12 / Python 3.11.2**, covering Modbus parsing and retries, scaling, month boundaries, missing measurements, PNG generation and preservation of static post content. Tests use simulated network responses; compatibility with every Lemmy deployment is not implied.
|
||||
|
||||
## A real working weather station
|
||||
|
||||
This project is not just a theoretical idea. It is based on a **physical, operating weather station that sends real measurements to an active Lemmy instance**. The configurable application in this repository was prepared from that working deployment.
|
||||
|
||||
**Live weather post:** [Stacja meteo — Szymbark](https://szymbark.net/post/171)
|
||||
|
||||
The screenshot below was captured from the actual public post on 13 October 2026. It shows the measured values, station status and three monthly charts; it is not a mockup.
|
||||
|
||||

|
||||
|
||||
**Lemmy instance homepage:** [szymbark.net](http://szymbark.net)
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
MODBUS METEO -> LEMMY
|
||||
Wersja przenośna, przygotowana do publikacji kodu źródłowego
|
||||
|
||||
CO ROBI PROGRAM
|
||||
Odczytuje temperaturę, wilgotność i ciśnienie z urządzenia Modbus TCP.
|
||||
Przelicza każdy rejestr według osobnego mnożnika, zapisuje historię w SQLite,
|
||||
tworzy trzy wykresy bieżącego miesiąca i aktualizuje jeden wpis Lemmy.
|
||||
Wpis zawiera wartości, krótki status stacji, czas pomiaru i wykresy.
|
||||
Użytkownik może zachować stały opis pod wykresami, np. parametry czujników.
|
||||
|
||||
Wszystkie adresy urządzeń, adres instancji i dane konta podaje użytkownik.
|
||||
Szablon config.example.json nie zawiera danych wdrożenia ani sekretów.
|
||||
Konfiguracja rzeczywista config.json jest ignorowana przez Git.
|
||||
|
||||
WYMAGANIA I ZAKRES
|
||||
- Debian/Linux z Pythonem 3.11 lub nowszym; sprawdzono na Debianie 12 / 3.11.2.
|
||||
- Pakiety systemowe: python3, python3-venv, cron, ca-certificates; opcjonalnie mc.
|
||||
- Dostęp sieciowy do urządzenia Modbus i HTTPS do własnej instancji Lemmy.
|
||||
- Aktywne konto Lemmy mogące publikować w wybranej społeczności.
|
||||
- Przypinanie wymaga uprawnień moderatora społeczności lub administratora.
|
||||
- API Lemmy v3, oparte na działającym wdrożeniu 0.19.11. API v4 nie jest
|
||||
zaimplementowane; przed inną wersją serwera sprawdź zgodność.
|
||||
- Instancja musi pozwalać temu kontu wysyłać obrazy przez /pictrs/image.
|
||||
- Obsługiwane są pojedyncze rejestry 16-bitowe int16/uint16, FC03/FC04.
|
||||
Float32, pary rejestrów, kolejność słów i inne jednostki wymagają rozbudowy.
|
||||
- Program wykonuje wyłącznie odczyt Modbus, nie zapisuje rejestrów.
|
||||
- Windows służy do przechowywania/edycji plików; uruchomienie wymaga Linuxa
|
||||
(blokady fcntl, CRON). Nie potrzeba Dockera ani serwera WWW dla klienta.
|
||||
|
||||
INSTALACJA KROK PO KROKU
|
||||
|
||||
1. Na docelowym Debianie przygotuj pakiety:
|
||||
sudo apt-get update
|
||||
sudo apt-get install python3 python3-venv cron ca-certificates mc
|
||||
Nie jest potrzebny restart serwera.
|
||||
|
||||
2. Skopiuj zawartość tego katalogu do wybranego folderu, np. ~/meteo.
|
||||
Dalsze polecenia wykonuj jako zwykły użytkownik, właściciel tego folderu:
|
||||
cd ~/meteo
|
||||
sh install.sh
|
||||
Instalator tworzy .venv, instaluje zależności, tworzy katalogi robocze
|
||||
i config.json, jeśli jeszcze nie istnieje. Nie włącza CRON-a ani publikacji.
|
||||
|
||||
3. Uzupełnij konfigurację:
|
||||
mcedit config.json
|
||||
Wpisz host/port/unit_id, adres każdego rejestru i mnożnik; sprawdź signed.
|
||||
Ustaw własną strefę czasową IANA. Szczegóły w KONFIGURACJA.txt.
|
||||
Na początku pozostaw lemmy.enabled=false.
|
||||
chmod 600 config.json
|
||||
.venv/bin/python settings.py
|
||||
To wyłącznie walidacja składni i pól; bez połączeń sieciowych.
|
||||
|
||||
4. Uruchom testy i rzeczywisty odczyt:
|
||||
.venv/bin/python -m unittest discover -v
|
||||
.venv/bin/python meteo.py
|
||||
.venv/bin/python charts.py
|
||||
Testy wykorzystują fikcyjne dane i mock API, nie kontaktują się z czujnikami
|
||||
ani instancją. Odczyt meteo.py kontaktuje się z urządzeniem i dopisuje próbę
|
||||
do historii. Obejrzyj data/status.json oraz trzy PNG w wykresy/.
|
||||
|
||||
5. Przygotuj konto i społeczność w Lemmy.
|
||||
Zwykłe hasło tego konta jest używane do uzyskania JWT przez API.
|
||||
Hasło aplikacji Google/SMTP nie jest hasłem konta Lemmy.
|
||||
Program nie tworzy użytkowników, nie zmienia bazy Lemmy ani uprawnień.
|
||||
Jeżeli konto wymaga 2FA/TOTP, ten klient nie obsługuje formularza TOTP.
|
||||
W config.json uzupełnij sekcję lemmy: instance (pełny adres HTTPS),
|
||||
username, password, community_id, title, heading i opcjonalne przypięcie.
|
||||
Community_id musi być lokalnym ID na docelowej instancji. Możesz uzyskać
|
||||
je z JSON jej API GET /api/v3/community?name=NAZWA_SPOLECZNOSCI:
|
||||
pole community_view.community.id. Nie myl nazwy widocznej z identyfikatorem.
|
||||
|
||||
6. Wybierz nowy lub istniejący wpis.
|
||||
Dla nowego pozostaw post_id=null. Opcjonalnie:
|
||||
cp static.example.txt static.txt
|
||||
mcedit static.txt
|
||||
Wpisz stały opis czujników. Będzie dołączony tylko podczas tworzenia wpisu.
|
||||
Późniejsze poprawki stałej części wykonuj już w edytorze Lemmy.
|
||||
Dla istniejącego własnego wpisu ustaw post_id i umieść w jego treści:
|
||||
|
||||
[meteo-start]: #
|
||||
|
||||
Tutaj program wstawi wyniki i wykresy.
|
||||
|
||||
[meteo-end]: #
|
||||
|
||||
Tutaj możesz dopisać stały opis czujników.
|
||||
|
||||
Oba znaczniki muszą wystąpić dokładnie raz, w tej kolejności i jako
|
||||
osobne wiersze. Puste wiersze są istotne dla renderowania Markdown.
|
||||
Program zachowuje treść przed i po sekcji. Przy braku znaczników odmawia
|
||||
edycji. Nie stosuj widocznych komentarzy HTML jako nowych znaczników.
|
||||
|
||||
7. Włącz lemmy.enabled=true. Wykonaj świadomie pierwszą publikację:
|
||||
.venv/bin/python settings.py
|
||||
.venv/bin/python run_cycle.py
|
||||
Polecenie odczyta pomiar, utworzy wykresy i utworzy/zaktualizuje wpis.
|
||||
Adres aktualizowanego wpisu znajdziesz w logs/meteo.log.
|
||||
Stan i ID wpisu zostaną zapisane w data/lemmy-state.json.
|
||||
Zweryfikuj wygląd wpisu oraz uprawnienia do przypięcia, jeśli je włączono.
|
||||
|
||||
8. Dopiero po udanym teście zainstaluj harmonogram:
|
||||
.venv/bin/python install_cron.py
|
||||
crontab -l
|
||||
systemctl is-active cron
|
||||
Jeśli usługa jest wyłączona: sudo systemctl enable --now cron
|
||||
Skrypt dodaje zadanie co pełną godzinę dla bieżącego użytkownika,
|
||||
z bezwzględną ścieżką do tej instalacji. Zachowuje inne zadania i zapisuje
|
||||
kopię poprzedniego crontab w data/. Zastępuje linię # meteo-collector:
|
||||
zakłada jedną stację dla tego użytkownika systemowego.
|
||||
|
||||
JAK DZIAŁA CYKL
|
||||
run_cycle.py -> meteo.py -> SQLite/status -> charts.py -> publish_lemmy.py.
|
||||
Odczyty trzech rejestrów są osobnymi zapytaniami, dzięki czemu mogą być
|
||||
oddalone i nie trzeba czytać niedostępnych rejestrów między nimi. Nie jest
|
||||
to atomowa próbka sprzętowa; wartości powstają w krótkich odstępach czasu.
|
||||
Przy niepowodzeniu dowolnego rejestru cała próba jest błędna i powtarzana.
|
||||
Błędy zapisywane są jako NULL, nie jako zerowe pomiary. Program pokazuje
|
||||
ostatnie dobre wartości i ich datę, jeśli aktualny odczyt zawiedzie.
|
||||
|
||||
Lemmy otrzymuje tekst Markdown, a wykresy pliki PNG (16:9, 1680x945).
|
||||
Status zielony oznacza poprawny, wystarczająco świeży odczyt wszystkich
|
||||
parametrów w chwili publikacji. Nie stanowi pełnej diagnostyki sprzętu.
|
||||
Gdy zatrzyma się cały program, internet lub Lemmy, post nie zmieni statusu
|
||||
samodzielnie: zostanie stara data. Niezależny alarm wymaga zewnętrznego nadzoru.
|
||||
|
||||
Stan publikacji zapobiega tworzeniu nowego posta co godzinę. Po niepewnym
|
||||
wyniku tworzenia program szuka własnego wpisu o tym samym tytule, społeczności
|
||||
i znacznikach w ostatnich 50 wpisach autora; jeśli wynik jest niejednoznaczny,
|
||||
odmawia tworzenia kolejnego. Administrator musi wskazać właściwy post_id.
|
||||
Zmiana konta/instancji/społeczności przy starym stanie jest blokowana.
|
||||
|
||||
Wykresy wybierają miesiąc według timezone. Baza przechowuje UTC. Przy zmianie
|
||||
miesiąca wykresy zaczynają się od nowa, ale historyczne pomiary zostają w bazie.
|
||||
Braki i zbyt długie odstępy pomiarów przerywają linie. Obrazy wysyłane są na
|
||||
instancję Lemmy. Starych obrazów program automatycznie nie usuwa; trzeba
|
||||
uwzględnić miejsce w pict-rs. Historia uploadów zawiera tokeny do usuwania.
|
||||
|
||||
OBSŁUGA I ZMIANY
|
||||
- Pełny cykl ręcznie: .venv/bin/python run_cycle.py
|
||||
- Sam pomiar: .venv/bin/python meteo.py
|
||||
- Bieżące wykresy: .venv/bin/python charts.py
|
||||
- Archiwalny miesiąc: .venv/bin/python charts.py --month RRRR-MM
|
||||
To nadpisuje lokalne PNG, więc przed publikacją przywróć bieżące wykresy.
|
||||
- Sama publikacja: .venv/bin/python publish_lemmy.py
|
||||
Wymaga istniejących, spójnych plików pomiarów i wykresów. Zwykle używaj cyklu.
|
||||
- Log: tail -n 60 logs/meteo.log
|
||||
- CRON: EDITOR=mcedit crontab -e
|
||||
- Wyłączenie zadania: .venv/bin/python install_cron.py --remove
|
||||
Nie usuwa plików ani wpisu. Nie uruchamiaj z sudo.
|
||||
- Przerwa tylko w publikowaniu: lemmy.enabled=false; pomiary i wykresy zostają.
|
||||
- Inna częstotliwość: edytuj crontab, stale_after_minutes i lemmy.update_note.
|
||||
Ponowne install_cron.py przywraca harmonogram godzinowy.
|
||||
- Tytuł jest używany przy tworzeniu/szukaniu wpisu; zmiana title w config
|
||||
nie przemianuje istniejącego posta. Zrób to w Lemmy i uaktualnij config.
|
||||
|
||||
PLIKI
|
||||
settings.py wczytanie i kontrola konfiguracji
|
||||
config.example.json pusty szablon ustawień, przeznaczony do publikacji
|
||||
config.json dane użytkownika, powstaje przy instalacji, NIE publikuj
|
||||
meteo.py Modbus, skalowanie, SQLite, status
|
||||
charts.py trzy wykresy i manifest zgodności danych
|
||||
publish_lemmy.py JWT, przesyłanie obrazów, edycja jednego wpisu
|
||||
run_cycle.py kolejność i blokada cyklu
|
||||
install.sh venv, zależności, katalogi; bez CRON-a
|
||||
install_cron.py harmonogram i kopia poprzedniego crontab
|
||||
static.example.txt wzór stałej sekcji posta
|
||||
static.txt własny opis, używany przy pierwszym tworzeniu
|
||||
requirements.lock.txt zamrożone wersje bibliotek wykresów
|
||||
test_*.py testy bez korzystania z serwerów produkcyjnych
|
||||
data/ SQLite, status, JWT, ID wpisu, historia uploadów
|
||||
wykresy/ PNG i manifest.json
|
||||
logs/ log aplikacji i wyjście CRON-a
|
||||
.cache/, .venv/, tmp/ lokalne cache, środowisko i pliki pomocnicze
|
||||
|
||||
PRZENIESIENIE NA KOLEJNY SERWER
|
||||
1. Wyłącz stare zadanie CRON i poczekaj na zakończenie aktualnego cyklu.
|
||||
2. Skopiuj kod, config.json, static.txt (jeśli istnieje), cały data/ oraz
|
||||
opcjonalnie wykresy/ i logs/. Zatrzymanej aplikacji nie uruchamiaj podczas
|
||||
kopiowania. Kopiując bazę na żywo użyj sqlite3.Connection.backup(), nie
|
||||
kopiuj samego pliku sqlite3 z pominięciem aktywnego WAL.
|
||||
3. Na nowym serwerze wykonaj sh install.sh. Zachowuje istniejący config.json.
|
||||
4. Sprawdź właściciela plików i chmod 600 config.json; folder data powinien
|
||||
być prywatny. Uruchom test, jeden pełny cykl i dopiero potem nowy CRON.
|
||||
5. Nie uruchamiaj dwóch serwerów publikujących do jednego wpisu równocześnie.
|
||||
Dla innej instancji/konta odłóż stare pliki data/lemmy-* do prywatnego backupu,
|
||||
uzupełnij nowe ustawienia i post_id. Nie mieszaj stanów dwóch instalacji.
|
||||
|
||||
PRZED PUBLIKACJĄ W GITHUBIE
|
||||
Publikuj zawartość tylko tego katalogu. Nie dołączaj konfiguracji rzeczywistej,
|
||||
SQLite, tokenów, logów, wykresów, archiwów ani plików prywatnych z katalogu
|
||||
nadrzędnego. .gitignore ogranicza przypadkowe dodanie, ale nie usuwa sekretów
|
||||
z istniejącej historii Git. Przed git add/commit sprawdź git status i diff.
|
||||
Nie dodano licencji bez decyzji właściciela kodu. Przed udostępnieniem jako
|
||||
projekt open source wybierz licencję i dodaj jej treść, jeśli tego chcesz.
|
||||
|
||||
Wersja nie instaluje SMTP ani hooków YunoHost — to konfiguracja instancji,
|
||||
a klient korzysta wyłącznie z Modbus, SQLite i uwierzytelnionego API Lemmy.
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate three monthly PNG charts from the recorded observations."""
|
||||
import argparse
|
||||
import calendar
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from settings import load as load_config
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
os.environ['MPLCONFIGDIR'] = str(BASE / '.cache/matplotlib')
|
||||
os.environ['XDG_CACHE_HOME'] = str(BASE / '.cache')
|
||||
MONTHS = ('styczeń', 'luty', 'marzec', 'kwiecień', 'maj', 'czerwiec',
|
||||
'lipiec', 'sierpień', 'wrzesień', 'październik', 'listopad', 'grudzień')
|
||||
SPECS = (
|
||||
('temperatura', 'Temperatura', '°C', '#C15E29', 2),
|
||||
('wilgotnosc', 'Wilgotność powietrza', '%', '#2176B5', 3),
|
||||
('cisnienie', 'Ciśnienie atmosferyczne', 'hPa', '#18847B', 4),
|
||||
)
|
||||
|
||||
|
||||
def month_bounds(year, month, tz):
|
||||
start = datetime(year, month, 1, tzinfo=tz)
|
||||
end = datetime(year + (month == 12), 1 if month == 12 else month + 1, 1, tzinfo=tz)
|
||||
return start, end
|
||||
|
||||
|
||||
def load_rows(db_path, start, end, now):
|
||||
with sqlite3.connect(db_path.resolve().as_uri() + '?mode=ro', uri=True) as db:
|
||||
return db.execute('''SELECT checked_at,status,temperature_c,humidity_percent,pressure_hpa
|
||||
FROM readings WHERE checked_at >= ? AND checked_at < ? AND checked_at <= ?
|
||||
ORDER BY checked_at,id''', tuple(
|
||||
value.astimezone(timezone.utc).isoformat(timespec='seconds')
|
||||
for value in (start, end, now))).fetchall()
|
||||
|
||||
|
||||
def series(rows, column, tz, max_gap_minutes=90):
|
||||
x, y = [], []
|
||||
previous = None
|
||||
for row in rows:
|
||||
stamp = datetime.fromisoformat(row[0])
|
||||
if previous is not None and (stamp - previous).total_seconds() > max_gap_minutes * 60:
|
||||
x.append((previous + timedelta(seconds=1)).astimezone(tz))
|
||||
y.append(float('nan'))
|
||||
x.append(stamp.astimezone(tz))
|
||||
value = row[column]
|
||||
y.append(float(value) if row[1] == 'ok' and value is not None else float('nan'))
|
||||
previous = stamp
|
||||
return x, y
|
||||
|
||||
|
||||
def number(value):
|
||||
return f'{value:.1f}'.replace('.', ',')
|
||||
|
||||
|
||||
def generate(month=None, *, db_path=None, output=None, now=None):
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
from matplotlib.ticker import FuncFormatter, MaxNLocator
|
||||
|
||||
os.umask(0o077)
|
||||
config = load_config()
|
||||
tz = ZoneInfo(config['timezone'])
|
||||
now = now or datetime.now(tz)
|
||||
local_now = now.astimezone(tz)
|
||||
year, month_number = map(int, month.split('-')) if month else (local_now.year, local_now.month)
|
||||
start, end = month_bounds(year, month_number, tz)
|
||||
rows = load_rows(db_path or BASE / 'data/meteo.sqlite3', start, end, now)
|
||||
output = output or BASE / 'wykresy'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
month_label = f'{MONTHS[month_number - 1]} {year}'
|
||||
plt.rcParams.update({'font.family': 'DejaVu Sans', 'font.size': 12,
|
||||
'axes.unicode_minus': True, 'axes.formatter.useoffset': False})
|
||||
manifest = {'month': f'{year:04d}-{month_number:02d}',
|
||||
'generated_at': local_now.isoformat(timespec='seconds'),
|
||||
'timezone': config['timezone'], 'charts': {}}
|
||||
for slug, title, unit, color, column in SPECS:
|
||||
x, y = series(rows, column, tz, config["stale_after_minutes"])
|
||||
valid = [(stamp, value) for stamp, value in zip(x, y) if math.isfinite(value)]
|
||||
# Use a 16:9 canvas compatible with common image preview crops.
|
||||
fig, ax = plt.subplots(figsize=(14, 7.875), dpi=120)
|
||||
fig.patch.set_facecolor('#F5F7FA')
|
||||
ax.set_facecolor('white')
|
||||
fig.subplots_adjust(left=.09, right=.96, bottom=.22, top=.69)
|
||||
fig.text(.09, .92, 'STACJA METEO / ' + month_label.upper(),
|
||||
color='#64748B', fontsize=11, weight='bold')
|
||||
fig.text(.09, .845, title, fontsize=25, weight='bold', color='#172B42')
|
||||
if valid:
|
||||
last_time, last_value = valid[-1]
|
||||
vals = [v for _, v in valid]
|
||||
fig.text(.96, .845, f'{number(last_value)} {unit}', ha='right',
|
||||
fontsize=29, weight='bold', color=color)
|
||||
fig.text(.96, .79, 'Ostatni pomiar: ' + last_time.strftime('%d.%m, %H:%M'),
|
||||
ha='right', fontsize=10, color='#64748B')
|
||||
fig.text(.09, .775,
|
||||
f'Minimum: {number(min(vals))} {unit} • Maksimum: {number(max(vals))} {unit}',
|
||||
fontsize=12, color='#475569')
|
||||
ax.plot(x, y, color=color, linewidth=1.7, marker='o',
|
||||
markersize=4 if len(valid) < 15 else 2, zorder=3)
|
||||
ax.scatter([last_time], [last_value], s=55, facecolor=color,
|
||||
edgecolor='white', linewidth=1.3, zorder=4)
|
||||
if slug == 'wilgotnosc':
|
||||
ax.set_ylim(0, 100)
|
||||
else:
|
||||
padding = max((max(vals) - min(vals)) * .15, 1 if slug == 'temperatura' else 2)
|
||||
ax.set_ylim(min(vals) - padding, max(vals) + padding)
|
||||
else:
|
||||
fig.text(.96, .845, 'Brak pomiarów', ha='right', fontsize=19, color='#64748B')
|
||||
ax.text(.5, .5, 'Brak poprawnych pomiarów w tym miesiącu', transform=ax.transAxes,
|
||||
ha='center', va='center', color='#64748B', fontsize=14)
|
||||
ax.set_ylim(0, 100 if slug == 'wilgotnosc' else 1)
|
||||
ax.set_yticks([])
|
||||
ax.set_xlim(start, end - timedelta(seconds=1))
|
||||
if start <= local_now < end:
|
||||
ax.axvspan(local_now, end, color='#F1F5F9', zorder=0)
|
||||
days = [1, 5, 10, 15, 20, 25, calendar.monthrange(year, month_number)[1]]
|
||||
ax.set_xticks([datetime(year, month_number, day, tzinfo=tz) for day in days])
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter('%d', tz=tz))
|
||||
ax.set_xlabel('Dzień miesiąca', labelpad=10, color='#475569')
|
||||
ax.set_ylabel(unit, labelpad=12, color='#475569')
|
||||
if valid:
|
||||
ax.yaxis.set_major_locator(MaxNLocator(nbins=5))
|
||||
ax.yaxis.set_major_formatter(FuncFormatter(lambda value, _: f'{value:g}'.replace('.', ',')))
|
||||
ax.grid(axis='y', color='#E2E8F0', linewidth=.8)
|
||||
ax.grid(axis='x', color='#EDF1F5', linewidth=.6)
|
||||
ax.set_axisbelow(True)
|
||||
ax.tick_params(axis='both', length=0, pad=9, colors='#475569')
|
||||
for spine in ax.spines.values():
|
||||
spine.set_visible(False)
|
||||
footer = f'Liczba poprawnych pomiarów: {len(valid)}'
|
||||
if valid:
|
||||
footer += ' • Dane od ' + valid[0][0].strftime('%d.%m, %H:%M')
|
||||
fig.text(.09, .095, footer, fontsize=10, color='#64748B')
|
||||
fig.text(.09, .052, 'Przerwy oznaczają brak danych. Czas lokalny: ' + config['timezone'],
|
||||
fontsize=9, color='#64748B')
|
||||
fig.text(.96, .052, 'Aktualizacja: ' + local_now.strftime('%d.%m.%Y, %H:%M'),
|
||||
ha='right', fontsize=9, color='#64748B')
|
||||
target = output / f'{slug}.png'
|
||||
temporary = target.with_suffix('.tmp')
|
||||
try:
|
||||
fig.savefig(temporary, format='png', dpi=120, facecolor=fig.get_facecolor(),
|
||||
metadata={'Title': f'{title} — {month_label}'})
|
||||
temporary.replace(target)
|
||||
finally:
|
||||
plt.close(fig)
|
||||
manifest['charts'][slug] = {'file': target.name, 'count': len(valid),
|
||||
'last_success_at': valid[-1][0].isoformat() if valid else None}
|
||||
temp_manifest = output / 'manifest.tmp'
|
||||
temp_manifest.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
temp_manifest.replace(output / 'manifest.json')
|
||||
return manifest
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--month', help='Optional month YYYY-MM; defaults to current local month')
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(generate(args.month), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"timezone": "UTC",
|
||||
"stale_after_minutes": 90,
|
||||
"modbus": {
|
||||
"host": "",
|
||||
"port": 502,
|
||||
"unit_id": 1,
|
||||
"function_code": 3,
|
||||
"timeout_seconds": 5,
|
||||
"attempts": 3,
|
||||
"retry_delay_seconds": 2
|
||||
},
|
||||
"sensors": {
|
||||
"temperature": {
|
||||
"address": null,
|
||||
"signed": true,
|
||||
"multiplier": 0.1,
|
||||
"offset": 0,
|
||||
"min": -100,
|
||||
"max": 100
|
||||
},
|
||||
"humidity": {
|
||||
"address": null,
|
||||
"signed": false,
|
||||
"multiplier": 0.1,
|
||||
"offset": 0,
|
||||
"min": 0,
|
||||
"max": 100
|
||||
},
|
||||
"pressure": {
|
||||
"address": null,
|
||||
"signed": false,
|
||||
"multiplier": 0.1,
|
||||
"offset": 0,
|
||||
"min": 100,
|
||||
"max": 1200
|
||||
}
|
||||
},
|
||||
"lemmy": {
|
||||
"enabled": false,
|
||||
"instance": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"community_id": null,
|
||||
"post_id": null,
|
||||
"title": "Stacja meteo",
|
||||
"heading": "Stacja meteo",
|
||||
"pin_in_community": false,
|
||||
"update_note": "Aktualizacja co godzinę. Wykresy obejmują bieżący miesiąc."
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
umask 077
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements.lock.txt
|
||||
mkdir -p data logs wykresy tmp
|
||||
if [ ! -f config.json ]; then cp config.example.json config.json; fi
|
||||
chmod 600 config.json
|
||||
printf '%s\n' 'Środowisko gotowe. Uzupełnij config.json w mcedit. CRON nie został włączony.'
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Install hourly job for the current Unix user; preserve unrelated jobs."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from settings import load
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
MARKER = '# meteo-collector'
|
||||
|
||||
def entry(base):
|
||||
if any(c in str(base) for c in ('%', '\n', '\r')):
|
||||
raise ValueError('CRON path cannot contain percent signs or newlines')
|
||||
return ('0 * * * * ' + shlex.quote(str(base / '.venv/bin/python')) + ' '
|
||||
+ shlex.quote(str(base / 'run_cycle.py')) + ' --quiet >> '
|
||||
+ shlex.quote(str(base / 'logs/cron.log')) + ' 2>&1 ' + MARKER)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--remove', action='store_true')
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
if not args.remove:
|
||||
load()
|
||||
if not (BASE / '.venv/bin/python').exists():
|
||||
raise SystemExit('Run install.sh first')
|
||||
result = subprocess.run(['crontab', '-l'], capture_output=True, text=True,
|
||||
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C'})
|
||||
if result.returncode == 0:
|
||||
previous = result.stdout
|
||||
elif result.returncode == 1 and 'no crontab for' in result.stderr:
|
||||
previous = ''
|
||||
else:
|
||||
raise SystemExit('Cannot read crontab')
|
||||
(BASE / 'data').mkdir(exist_ok=True)
|
||||
(BASE / 'logs').mkdir(exist_ok=True)
|
||||
backup = BASE / 'data' / ('crontab-before-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') + '.txt')
|
||||
backup.write_text(previous, encoding='utf-8')
|
||||
lines = [line for line in previous.splitlines() if not line.rstrip().endswith(MARKER)]
|
||||
if not args.remove:
|
||||
lines.append(entry(BASE))
|
||||
updated = '\n'.join(lines) + '\n'
|
||||
subprocess.run(['crontab', '-'], input=updated, text=True, check=True)
|
||||
if subprocess.check_output(['crontab', '-l'], text=True) != updated:
|
||||
raise SystemExit('Crontab verification failed')
|
||||
print('CRON zaktualizowany; kopia poprzedniej konfiguracji w data/.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only Modbus TCP weather collector. Python standard library only."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from settings import load as load_config
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
FIELDS = ("temperature", "humidity", "pressure")
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def read_exact(sock, size):
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = sock.recv(size - len(data))
|
||||
if not chunk:
|
||||
raise OSError("Urządzenie zamknęło połączenie przed końcem odpowiedzi")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def read_registers(settings, start, count):
|
||||
unit = settings["unit_id"]
|
||||
function = settings["function_code"]
|
||||
request = struct.pack(">HHHBBHH", 1, 0, 6, unit, function, start, count)
|
||||
with socket.create_connection(
|
||||
(settings["host"], settings["port"]), timeout=settings["timeout_seconds"]
|
||||
) as sock:
|
||||
sock.settimeout(settings["timeout_seconds"])
|
||||
sock.sendall(request)
|
||||
transaction, protocol, length, response_unit = struct.unpack(
|
||||
">HHHB", read_exact(sock, 7)
|
||||
)
|
||||
if (transaction, protocol, response_unit) != (1, 0, unit):
|
||||
raise ValueError("Nieprawidłowy nagłówek odpowiedzi Modbus")
|
||||
if not 2 <= length <= 254:
|
||||
raise ValueError("Nieprawidłowa długość odpowiedzi Modbus")
|
||||
payload = read_exact(sock, length - 1)
|
||||
if payload[0] == (function | 0x80):
|
||||
code = payload[1] if len(payload) > 1 else "brak kodu"
|
||||
raise ValueError(f"Wyjątek Modbus: {code}")
|
||||
if len(payload) != 2 + count * 2 or payload[:2] != bytes((function, count * 2)):
|
||||
raise ValueError("Nieprawidłowa funkcja lub liczba bajtów Modbus")
|
||||
return struct.unpack(">" + "H" * count, payload[2:])
|
||||
|
||||
|
||||
def decode(registers, start, sensors):
|
||||
values, raw = {}, {}
|
||||
for name in FIELDS:
|
||||
sensor = sensors[name]
|
||||
number = registers[sensor["address"] - start]
|
||||
raw[name] = number
|
||||
if sensor["signed"] and number >= 32768:
|
||||
number -= 65536
|
||||
value = number * sensor["multiplier"] + sensor["offset"]
|
||||
if not math.isfinite(value) or not sensor["min"] <= value <= sensor["max"]:
|
||||
raise ValueError(f"{name}: wynik {value} poza zakresem konfiguracji")
|
||||
values[name] = value
|
||||
return values, raw
|
||||
|
||||
|
||||
def collect(config):
|
||||
settings = config["modbus"]
|
||||
attempts = settings["attempts"]
|
||||
if not 1 <= attempts <= 10:
|
||||
raise ValueError("Liczba prób musi wynosić od 1 do 10")
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
# Read only configured registers, including widely separated addresses.
|
||||
registers = {}
|
||||
for name in FIELDS:
|
||||
address = config['sensors'][name]['address']
|
||||
if address not in registers:
|
||||
registers[address] = read_registers(settings, address, 1)[0]
|
||||
values, raw = decode(registers, 0, config['sensors'])
|
||||
return utc_now(), values, raw, attempt, None
|
||||
except (OSError, ValueError) as exc:
|
||||
logging.warning("Próba %s/%s: %s", attempt, attempts, exc)
|
||||
if attempt < attempts:
|
||||
time.sleep(settings["retry_delay_seconds"])
|
||||
else:
|
||||
return utc_now(), None, None, attempt, str(exc)
|
||||
|
||||
|
||||
def save_result(db_path, checked_at, values, raw, attempts, error):
|
||||
with sqlite3.connect(db_path, timeout=10) as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS readings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
checked_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('ok', 'error')),
|
||||
temperature_c REAL, humidity_percent REAL, pressure_hpa REAL,
|
||||
raw_json TEXT, attempts INTEGER NOT NULL, error TEXT
|
||||
)""")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS readings_time ON readings(checked_at)")
|
||||
db.execute("""INSERT INTO readings
|
||||
(checked_at,status,temperature_c,humidity_percent,pressure_hpa,raw_json,attempts,error)
|
||||
VALUES (?,?,?,?,?,?,?,?)""", (
|
||||
checked_at, "ok" if values is not None else "error",
|
||||
*((values[name] if values is not None else None) for name in FIELDS),
|
||||
json.dumps(raw) if raw is not None else None, attempts, error
|
||||
))
|
||||
last_good = db.execute("""SELECT checked_at,temperature_c,humidity_percent,pressure_hpa
|
||||
FROM readings WHERE status='ok' ORDER BY id DESC LIMIT 1""").fetchone()
|
||||
return last_good
|
||||
|
||||
|
||||
def write_status(path, checked_at, values, last_good, local_timezone):
|
||||
status = {
|
||||
"status": "ok" if values is not None else "error",
|
||||
"label": "Działa poprawnie" if values is not None else "Błąd stacji",
|
||||
"checked_at": checked_at,
|
||||
"checked_at_local": datetime.fromisoformat(checked_at).astimezone(
|
||||
ZoneInfo(local_timezone)).isoformat(),
|
||||
"values": values,
|
||||
"last_success_at": last_good[0] if last_good else None,
|
||||
"last_success_values": dict(zip(FIELDS, last_good[1:])) if last_good else None,
|
||||
}
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
return status
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o077)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--quiet", action="store_true", help="Log to file without terminal output")
|
||||
args = parser.parse_args()
|
||||
(BASE / "data").mkdir(exist_ok=True)
|
||||
(BASE / "logs").mkdir(exist_ok=True)
|
||||
handler = RotatingFileHandler(BASE / "logs/meteo.log", maxBytes=2_000_000,
|
||||
backupCount=5, encoding="utf-8")
|
||||
logging.basicConfig(level=logging.INFO, handlers=[handler],
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
with (BASE / "data/meteo.lock").open("a") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
logging.info("Poprzedni odczyt jeszcze trwa; pomijam uruchomienie")
|
||||
return 0
|
||||
try:
|
||||
config = load_config()
|
||||
ZoneInfo(config["timezone"])
|
||||
checked_at, values, raw, attempts, error = collect(config)
|
||||
last_good = save_result(BASE / "data/meteo.sqlite3", checked_at,
|
||||
values, raw, attempts, error)
|
||||
status = write_status(BASE / "data/status.json", checked_at,
|
||||
values, last_good, config["timezone"])
|
||||
if values is not None:
|
||||
logging.info("Pomiar: temperatura=%s °C; wilgotność=%s %%; ciśnienie=%s hPa",
|
||||
values["temperature"], values["humidity"], values["pressure"])
|
||||
else:
|
||||
logging.error("Błąd stacji: %s", error)
|
||||
if not args.quiet:
|
||||
print(json.dumps(status, ensure_ascii=False, indent=2))
|
||||
return 0 if values is not None else 1
|
||||
except Exception:
|
||||
logging.exception("Błąd programu lub zapisu danych")
|
||||
if not args.quiet:
|
||||
print("Błąd programu. Szczegóły: " + str(BASE / "logs/meteo.log"), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a single weather post and uploaded charts through Lemmy API v3."""
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from settings import load as load_config
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
START = '[meteo-start]: #'
|
||||
END = '[meteo-end]: #'
|
||||
LEGACY_START = '<!-- METEO:START -->'
|
||||
LEGACY_END = '<!-- METEO:END -->'
|
||||
CHARTS = [('temperatura', 'Temperatura'), ('wilgotnosc', 'Wilgotność powietrza'),
|
||||
('cisnienie', 'Ciśnienie atmosferyczne')]
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
temporary = path.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status, code):
|
||||
super().__init__(f'Lemmy HTTP {status}: {code}')
|
||||
self.status = status
|
||||
self.code = code
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise ApiError(code, 'redirect_refused; configure canonical instance address')
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.secrets = load_config()['lemmy']
|
||||
self.base = self.secrets['instance'].rstrip('/')
|
||||
self.token_path = BASE / 'data/lemmy-token.json'
|
||||
self.token = None
|
||||
if self.token_path.exists():
|
||||
cached = read_json(self.token_path)
|
||||
if cached.get('instance') == self.base and cached.get('username') == self.secrets['username']:
|
||||
self.token = cached.get('jwt')
|
||||
|
||||
def request(self, path, data=None, method=None, content_type='application/json', auth=True, retry=True):
|
||||
if auth and not self.token:
|
||||
self.login()
|
||||
headers = {'User-Agent': 'MeteoStation/1.0', 'Accept': 'application/json'}
|
||||
if auth:
|
||||
headers['Authorization'] = 'Bearer ' + self.token
|
||||
if data is not None:
|
||||
headers['Content-Type'] = content_type
|
||||
if not isinstance(data, bytes):
|
||||
data = json.dumps(data).encode('utf-8')
|
||||
request = Request(self.base + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with build_opener(NoRedirect()).open(request, timeout=45) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
try:
|
||||
code = json.loads(exc.read()).get('error', 'api_error')
|
||||
except Exception:
|
||||
code = 'non_json_error'
|
||||
if auth and retry and (exc.code == 401 or code in ('not_logged_in', 'incorrect_login')):
|
||||
self.login()
|
||||
return self.request(path, data, method, content_type, auth=True, retry=False)
|
||||
raise ApiError(exc.code, code) from None
|
||||
|
||||
def login(self):
|
||||
result = self.request('/api/v3/user/login', {
|
||||
'username_or_email': self.secrets['username'], 'password': self.secrets['password']},
|
||||
method='POST', auth=False)
|
||||
if not result.get('jwt'):
|
||||
raise ValueError('Login did not return a token')
|
||||
self.token = result['jwt']
|
||||
save_json(self.token_path, {'jwt': self.token, 'instance': self.base, 'username': self.secrets['username']})
|
||||
|
||||
def upload(self, filename):
|
||||
boundary = 'meteo-' + uuid.uuid4().hex
|
||||
header = (f'--{boundary}\r\nContent-Disposition: form-data; name="images[]"; '
|
||||
f'filename="{filename.name}"\r\nContent-Type: image/png\r\n\r\n').encode()
|
||||
body = header + filename.read_bytes() + f'\r\n--{boundary}--\r\n'.encode()
|
||||
result = self.request('/pictrs/image', body, 'POST', 'multipart/form-data; boundary=' + boundary)
|
||||
files = result.get('files', [])
|
||||
if len(files) != 1:
|
||||
raise ValueError('Image upload did not return exactly one image')
|
||||
entry = files[0]
|
||||
return {'url': self.base + '/pictrs/image/' + quote(entry['file'], safe=''),
|
||||
'file': entry['file'], 'delete_token': entry.get('delete_token')}
|
||||
|
||||
|
||||
def merge_body(existing, section):
|
||||
start, end = (LEGACY_START, LEGACY_END) if LEGACY_START in existing else (START, END)
|
||||
if existing.count(start) != 1 or existing.count(end) != 1:
|
||||
raise ValueError('Weather markers missing or duplicated; post left unchanged')
|
||||
before, rest = existing.split(start)
|
||||
middle, after = rest.split(end)
|
||||
return before + START + '\n\n' + section + '\n\n' + END + after
|
||||
|
||||
|
||||
def render_section(status, images, now=None, config=None):
|
||||
config = config or load_config()
|
||||
now = now or datetime.now(timezone.utc)
|
||||
checked = datetime.fromisoformat(status['checked_at'])
|
||||
age = (now - checked).total_seconds()
|
||||
healthy = status['status'] == 'ok' and 0 <= age <= config['stale_after_minutes'] * 60
|
||||
values = status.get('values') if healthy else status.get('last_success_values')
|
||||
def value(name, unit):
|
||||
return ('—' if not values else f"{values[name]:.1f}".replace('.', ',') + ' ' + unit)
|
||||
# Non-breaking-space paragraphs retain vertical spacing in Lemmy Markdown.
|
||||
lines = ['## ' + config['lemmy']['heading'], '', '\u00a0', '', '\u00a0', '',
|
||||
f"**Temperatura:** {value('temperature', '°C')} ",
|
||||
f"**Wilgotność:** {value('humidity', '%')} ",
|
||||
f"**Ciśnienie:** {value('pressure', 'hPa')} ",
|
||||
'**Status:** ' + ('🟢 Działa poprawnie' if healthy else '🔴 Błąd stacji') + ' ']
|
||||
stamp = status.get('last_success_at')
|
||||
shown = datetime.fromisoformat(stamp).astimezone(ZoneInfo(config['timezone'])).strftime('%d.%m.%Y, %H:%M') if stamp else 'brak'
|
||||
lines.append('**Ostatni poprawny pomiar:** ' + shown)
|
||||
if not healthy and values:
|
||||
lines.append('\nPodane wartości pochodzą z ostatniego poprawnego pomiaru.')
|
||||
lines.append('\n' + config['lemmy']['update_note'])
|
||||
for slug, title in CHARTS:
|
||||
lines.extend(['', f'### {title}', '', f""])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def publish():
|
||||
os.umask(0o077)
|
||||
config = load_config()
|
||||
settings = config['lemmy']
|
||||
if not settings.get('enabled'):
|
||||
return None
|
||||
(BASE / 'data').mkdir(exist_ok=True)
|
||||
with (BASE / 'data/publish.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
client = Client()
|
||||
identity = client.request('/api/v3/site')['my_user']['local_user_view']['person']['id']
|
||||
state_path = BASE / 'data/lemmy-state.json'
|
||||
state = read_json(state_path) if state_path.exists() else {'images': {}}
|
||||
destination = {'instance': client.base, 'author_id': identity, 'community_id': settings['community_id']}
|
||||
if state.get('destination') not in (None, destination):
|
||||
raise ValueError('Destination changed: archive local publication state before using a different account or community')
|
||||
if settings['post_id'] and state.get('post_id') and settings['post_id'] != state['post_id']:
|
||||
raise ValueError('Configured post_id differs from saved state')
|
||||
state['destination'] = destination
|
||||
status = read_json(BASE / 'data/status.json')
|
||||
manifest = read_json(BASE / 'wykresy/manifest.json')
|
||||
if manifest['month'] != datetime.now(ZoneInfo(config['timezone'])).strftime('%Y-%m'):
|
||||
raise ValueError('Chart month is not current; publication deferred')
|
||||
for info in manifest['charts'].values():
|
||||
if info['last_success_at'] and status['last_success_at'] and datetime.fromisoformat(info['last_success_at']) != datetime.fromisoformat(status['last_success_at']):
|
||||
raise ValueError('Chart and measurement timestamps differ; publication deferred')
|
||||
post_id = state.get('post_id') or settings['post_id']
|
||||
if not post_id:
|
||||
result = client.request('/api/v3/user?person_id=' + str(identity) + '&limit=50&sort=New')
|
||||
candidates = [p['post'] for p in result.get('posts', []) if p['post']['community_id'] == settings['community_id']
|
||||
and p['post']['name'] == settings['title'] and any(marker in (p['post'].get('body') or '') for marker in (START, LEGACY_START))]
|
||||
if len(candidates) > 1:
|
||||
raise ValueError('Multiple matching weather posts; manual selection required')
|
||||
if candidates:
|
||||
post_id = state['post_id'] = candidates[0]['id']
|
||||
state.pop('creation_pending', None)
|
||||
save_json(state_path, state)
|
||||
elif state.get('creation_pending'):
|
||||
raise ValueError('Previous creation outcome uncertain; refusing duplicate post')
|
||||
if post_id:
|
||||
current = client.request('/api/v3/post?id=' + str(post_id))['post_view']['post']
|
||||
if current['creator_id'] != identity or current['community_id'] != settings['community_id']:
|
||||
raise ValueError('Post ownership or community mismatch')
|
||||
if current['deleted'] or current['removed']:
|
||||
raise ValueError('Post has been deleted or removed')
|
||||
merge_body(current.get('body') or '', '') # Validate markers before uploading.
|
||||
for slug, title in CHARTS:
|
||||
path = BASE / 'wykresy' / (slug + '.png')
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if state['images'].get(slug, {}).get('sha256') != digest:
|
||||
uploaded = client.upload(path)
|
||||
uploaded['sha256'] = digest
|
||||
with (BASE / 'data/lemmy-uploads.jsonl').open('a', encoding='utf-8') as archive:
|
||||
archive.write(json.dumps({'uploaded_at': datetime.now(timezone.utc).isoformat(),
|
||||
'chart': slug, **uploaded}) + '\n')
|
||||
state['images'][slug] = uploaded
|
||||
save_json(state_path, state)
|
||||
section = render_section(status, state['images'], config=config)
|
||||
if not post_id:
|
||||
state['creation_pending'] = True
|
||||
save_json(state_path, state)
|
||||
appendix = (BASE / 'static.txt').read_text(encoding='utf-8') if (BASE / 'static.txt').exists() else ''
|
||||
result = client.request('/api/v3/post', {'name': settings['title'], 'community_id': settings['community_id'],
|
||||
'body': START + '\n\n' + section + '\n\n' + END + '\n\n' + appendix, 'nsfw': False}, 'POST')
|
||||
post_id = state['post_id'] = result['post_view']['post']['id']
|
||||
state.pop('creation_pending', None)
|
||||
save_json(state_path, state)
|
||||
else:
|
||||
# Fetch again to preserve edits made while uploading images.
|
||||
current = client.request('/api/v3/post?id=' + str(post_id))['post_view']['post']
|
||||
body = merge_body(current.get('body') or '', section)
|
||||
if body != current.get('body'):
|
||||
client.request('/api/v3/post', {'post_id': post_id, 'body': body}, 'PUT')
|
||||
post = client.request('/api/v3/post?id=' + str(post_id))['post_view']['post']
|
||||
if settings.get('pin_in_community') and not post['featured_community']:
|
||||
client.request('/api/v3/post/feature', {'post_id': post_id, 'featured': True, 'feature_type': 'Community'}, 'POST')
|
||||
state['post_id'] = post_id
|
||||
state['last_published_at'] = datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||
state['last_measurement_at'] = status['checked_at']
|
||||
save_json(state_path, state)
|
||||
url = client.base + '/post/' + str(post_id)
|
||||
logging.info('Zaktualizowano wpis Lemmy: %s', url)
|
||||
return url
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(publish())
|
||||
@@ -0,0 +1,11 @@
|
||||
contourpy==1.3.3
|
||||
cycler==0.12.1
|
||||
fonttools==4.66.1
|
||||
kiwisolver==1.5.1
|
||||
matplotlib==3.11.2
|
||||
numpy==2.4.6
|
||||
packaging==26.3
|
||||
pillow==12.3.0
|
||||
pyparsing==3.3.3
|
||||
python-dateutil==2.9.0.post0
|
||||
six==1.17.0
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scheduled measurement followed by charts, including after a failed reading."""
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o077)
|
||||
(BASE / 'data').mkdir(exist_ok=True)
|
||||
with (BASE / 'data/cycle.lock').open('a') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return 0
|
||||
import meteo
|
||||
result = meteo.main()
|
||||
try:
|
||||
from charts import generate
|
||||
manifest = generate()
|
||||
logging.info('Wygenerowano 3 wykresy dla miesiąca %s', manifest['month'])
|
||||
except Exception:
|
||||
logging.exception('Nie udało się wygenerować wykresów')
|
||||
return 3
|
||||
if result != 2:
|
||||
try:
|
||||
from publish_lemmy import publish
|
||||
publish()
|
||||
except Exception:
|
||||
logging.exception('Nie udało się zaktualizować wpisu Lemmy')
|
||||
return 4
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
"""Configuration validation; never display credentials."""
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
FIELDS = ('temperature', 'humidity', 'pressure')
|
||||
|
||||
def validate(config):
|
||||
ZoneInfo(config['timezone'])
|
||||
if not isinstance(config['stale_after_minutes'], (int, float)) or not 1 <= config['stale_after_minutes'] <= 10080:
|
||||
raise ValueError('stale_after_minutes: expected 1..10080')
|
||||
m = config['modbus']
|
||||
if not isinstance(m['host'], str) or not m['host'].strip():
|
||||
raise ValueError('modbus.host: enter device address')
|
||||
for key, lo, hi in [('port', 1, 65535), ('unit_id', 0, 255), ('function_code', 3, 4), ('attempts', 1, 10)]:
|
||||
if type(m[key]) is not int or not lo <= m[key] <= hi:
|
||||
raise ValueError('modbus.' + key + ': invalid value')
|
||||
for key, lo, hi in [('timeout_seconds', .1, 120), ('retry_delay_seconds', 0, 60)]:
|
||||
if not isinstance(m[key], (int, float)) or not lo <= m[key] <= hi:
|
||||
raise ValueError('modbus.' + key + ': invalid value')
|
||||
for name in FIELDS:
|
||||
s = config['sensors'][name]
|
||||
if type(s['address']) is not int or not 0 <= s['address'] <= 65535:
|
||||
raise ValueError(name + '.address: enter a zero-based register address')
|
||||
if type(s['signed']) is not bool:
|
||||
raise ValueError(name + '.signed: expected boolean')
|
||||
for key in ('multiplier', 'offset', 'min', 'max'):
|
||||
if type(s[key]) not in (int, float) or not math.isfinite(s[key]):
|
||||
raise ValueError(name + '.' + key + ': expected finite number')
|
||||
if s['multiplier'] == 0 or s['min'] > s['max']:
|
||||
raise ValueError(name + ': invalid multiplier or range')
|
||||
l = config['lemmy']
|
||||
if type(l['enabled']) is not bool:
|
||||
raise ValueError('lemmy.enabled: expected boolean')
|
||||
if l['enabled']:
|
||||
u = urlsplit(l['instance'])
|
||||
if u.scheme != 'https' or not u.hostname or u.username or u.password or u.path not in ('', '/') or u.query or u.fragment:
|
||||
raise ValueError('lemmy.instance: expected HTTPS origin without credentials or path')
|
||||
for key in ('username', 'password', 'title', 'heading'):
|
||||
if not isinstance(l[key], str) or not l[key].strip():
|
||||
raise ValueError('lemmy.' + key + ': required')
|
||||
if type(l['community_id']) is not int or l['community_id'] <= 0:
|
||||
raise ValueError('lemmy.community_id: expected positive integer')
|
||||
if l['post_id'] is not None and (type(l['post_id']) is not int or l['post_id'] <= 0):
|
||||
raise ValueError('lemmy.post_id: expected null or positive integer')
|
||||
if type(l['pin_in_community']) is not bool:
|
||||
raise ValueError('lemmy.pin_in_community: expected boolean')
|
||||
return config
|
||||
|
||||
def load():
|
||||
return validate(json.loads((BASE / 'config.json').read_text(encoding='utf-8')))
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
load()
|
||||
print('Konfiguracja poprawna. Nie wykonano połączeń sieciowych.')
|
||||
except Exception as exc:
|
||||
raise SystemExit('Błąd konfiguracji: ' + str(exc))
|
||||
@@ -0,0 +1,5 @@
|
||||
### Zastosowane czujniki
|
||||
|
||||
| Czujnik temperatury i wilgotności | Czujnik ciśnienia |
|
||||
| --- | --- |
|
||||
| Wpisz model, zakresy i dokładność swojego czujnika. | Wpisz model, zakresy i dokładność swojego czujnika. |
|
||||
@@ -0,0 +1,68 @@
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from unittest.mock import patch
|
||||
from test_support import config
|
||||
import charts
|
||||
import meteo
|
||||
|
||||
|
||||
class ChartTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_config = patch("charts.load_config", return_value=config())
|
||||
self.mock_config.start()
|
||||
self.addCleanup(self.mock_config.stop)
|
||||
def test_month_bounds_dst_leap_and_year_end(self):
|
||||
tz = ZoneInfo('Europe/Warsaw')
|
||||
start, end = charts.month_bounds(2026, 10, tz)
|
||||
self.assertEqual(start.astimezone(timezone.utc).isoformat(), '2026-09-30T22:00:00+00:00')
|
||||
self.assertEqual(end.astimezone(timezone.utc).isoformat(), '2026-10-31T23:00:00+00:00')
|
||||
self.assertEqual((charts.month_bounds(2024, 2, tz)[1] - charts.month_bounds(2024, 2, tz)[0]).days, 29)
|
||||
self.assertEqual(charts.month_bounds(2026, 12, tz)[1].year, 2027)
|
||||
|
||||
def test_gaps_and_failed_readings(self):
|
||||
rows = [('2026-10-12T10:00:00+00:00', 'ok', 7.0),
|
||||
('2026-10-12T11:00:00+00:00', 'error', None),
|
||||
('2026-10-12T12:00:00+00:00', 'ok', 8.0),
|
||||
('2026-10-12T15:00:00+00:00', 'ok', 9.0)]
|
||||
x, y = charts.series(rows, 2, ZoneInfo('Europe/Warsaw'))
|
||||
self.assertEqual(len(y), 5)
|
||||
self.assertTrue(math.isnan(y[1]))
|
||||
self.assertTrue(math.isnan(y[3]))
|
||||
self.assertEqual([v for v in y if math.isfinite(v)], [7, 8, 9])
|
||||
|
||||
def test_local_month_selection_and_future_exclusion(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
db = Path(temporary) / 'test.sqlite3'
|
||||
for stamp in ['2026-09-30T21:59:59+00:00', '2026-09-30T22:00:00+00:00',
|
||||
'2026-10-12T10:00:00+00:00', '2026-10-31T23:00:00+00:00']:
|
||||
meteo.save_result(db, stamp, {'temperature': 7, 'humidity': 90, 'pressure': 1010}, {}, 1, None)
|
||||
tz = ZoneInfo('Europe/Warsaw')
|
||||
start, end = charts.month_bounds(2026, 10, tz)
|
||||
rows = charts.load_rows(db, start, end, datetime(2026, 10, 10, tzinfo=tz))
|
||||
self.assertEqual([row[0] for row in rows], ['2026-09-30T22:00:00+00:00'])
|
||||
|
||||
def test_render_empty_and_single_reading(self):
|
||||
from PIL import Image
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
db = root / 'test.sqlite3'
|
||||
meteo.save_result(db, '2026-10-12T10:00:00+00:00',
|
||||
{'temperature': -5, 'humidity': 100, 'pressure': 1010}, {}, 1, None)
|
||||
for month, expected in [('2026-09', 0), ('2026-10', 1)]:
|
||||
output = root / month
|
||||
manifest = charts.generate(month, db_path=db, output=output,
|
||||
now=datetime(2026, 10, 12, 12, tzinfo=timezone.utc))
|
||||
for chart in manifest['charts'].values():
|
||||
self.assertEqual(chart['count'], expected)
|
||||
with Image.open(output / chart['file']) as img:
|
||||
self.assertEqual(img.size, (1680, 945))
|
||||
img.verify()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import meteo
|
||||
import settings
|
||||
import publish_lemmy as publisher
|
||||
from install_cron import entry
|
||||
from test_meteo import FakeSocket
|
||||
from test_support import config
|
||||
|
||||
class GenericTests(unittest.TestCase):
|
||||
def test_example_requires_real_addresses(self):
|
||||
c = json.loads((settings.BASE / 'config.example.json').read_text())
|
||||
with self.assertRaises(ValueError):
|
||||
settings.validate(c)
|
||||
c = config()
|
||||
settings.validate(c)
|
||||
c['sensors']['pressure']['address'] = None
|
||||
with self.assertRaises(ValueError):
|
||||
settings.validate(c)
|
||||
|
||||
def test_signed_multiplier_offset_and_distant_addresses(self):
|
||||
c = config()
|
||||
c['sensors']['pressure']['address'] = 60000
|
||||
c['sensors']['temperature'].update(multiplier=.2, offset=1)
|
||||
values, raw = meteo.decode({20:65526, 23:450, 60000:10132}, 0, c['sensors'])
|
||||
self.assertEqual(values['temperature'], -1)
|
||||
self.assertAlmostEqual(values['pressure'], 1013.2)
|
||||
self.assertEqual(raw['temperature'], 65526)
|
||||
with patch('meteo.read_registers', side_effect=[(65526,), (450,), (10132,)]) as reader:
|
||||
result = meteo.collect(c)
|
||||
self.assertIsNone(result[-1])
|
||||
self.assertEqual([call.args[1:] for call in reader.call_args_list], [(20,1),(23,1),(60000,1)])
|
||||
|
||||
def test_input_registers_fc04(self):
|
||||
c = config()['modbus']
|
||||
c['function_code'] = 4
|
||||
fake = FakeSocket(struct.pack('>HHHBBBH', 1, 0, 5, 8, 4, 2, 234))
|
||||
with patch('meteo.socket.create_connection', return_value=fake):
|
||||
self.assertEqual(meteo.read_registers(c, 10, 1), (234,))
|
||||
self.assertEqual(fake.sent, struct.pack('>HHHBBHH', 1, 0, 6, 8, 4, 10, 1))
|
||||
|
||||
def test_cron_quotes_paths_and_rejects_percent(self):
|
||||
self.assertIn("'/tmp/weather station/run_cycle.py'", entry(Path('/tmp/weather station')))
|
||||
with self.assertRaises(ValueError):
|
||||
entry(Path('/tmp/weather%station'))
|
||||
|
||||
def test_publish_updates_existing_post_preserving_static_footer(self):
|
||||
c = config()
|
||||
c['lemmy'].update(enabled=True, instance='https://localhost', username='fixture', password='fixture', community_id=12, post_id=42)
|
||||
requests = []
|
||||
post = {'id':42,'creator_id':99,'community_id':12,'deleted':False,'removed':False,
|
||||
'featured_community':False,'body':'Intro\n'+publisher.START+'\n\nold\n\n'+publisher.END+'\n\nSensor specifications'}
|
||||
class FakeClient:
|
||||
base = 'https://localhost'
|
||||
def request(self, path, data=None, method=None):
|
||||
requests.append((path,data,method))
|
||||
if path == '/api/v3/site':
|
||||
return {'my_user':{'local_user_view':{'person':{'id':99}}}}
|
||||
if method == 'PUT':
|
||||
post['body'] = data['body']
|
||||
return {'post_view':{'post':post.copy()}}
|
||||
def upload(self, filename):
|
||||
return {'url':'/fixture.png', 'file':filename.name, 'delete_token':'fixture'}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
(base/'data').mkdir()
|
||||
(base/'wykresy').mkdir()
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
stamp = datetime.now(ZoneInfo(c['timezone'])).isoformat()
|
||||
values = dict(temperature=23.4,humidity=45,pressure=1013.2)
|
||||
(base/'data/status.json').write_text(json.dumps(dict(status='ok',checked_at=stamp,last_success_at=stamp,values=values,last_success_values=values)))
|
||||
(base/'wykresy/manifest.json').write_text(json.dumps({'month':stamp[:7], 'charts':{s:{'last_success_at':stamp} for s,_ in publisher.CHARTS}}))
|
||||
for slug,_ in publisher.CHARTS:
|
||||
(base/'wykresy'/ (slug+'.png')).write_bytes(b'fixture')
|
||||
with patch.object(publisher,'BASE',base), patch.object(publisher,'load_config',return_value=c), patch.object(publisher,'Client',FakeClient):
|
||||
publisher.publish()
|
||||
self.assertTrue(post['body'].endswith('Sensor specifications'))
|
||||
self.assertIn('23,4 °C',post['body'])
|
||||
self.assertFalse(any(method=='POST' and path=='/api/v3/post' for path,_,method in requests))
|
||||
self.assertEqual(json.loads((base/'data/lemmy-state.json').read_text())['post_id'],42)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import meteo
|
||||
|
||||
from test_support import config
|
||||
CONFIG = config()
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.sent = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def settimeout(self, timeout):
|
||||
pass
|
||||
|
||||
def sendall(self, request):
|
||||
self.sent = request
|
||||
|
||||
def recv(self, size):
|
||||
# Simulate fragmented TCP replies.
|
||||
size = min(size, 2)
|
||||
data, self.response = self.response[:size], self.response[size:]
|
||||
return data
|
||||
|
||||
|
||||
class CollectorTests(unittest.TestCase):
|
||||
def test_fragmented_response_and_read_only_request(self):
|
||||
response = struct.pack(">HHHBBB6H", 1, 0, 15, 8, 3, 12,
|
||||
65481, 67, 0, 951, 0, 10202)
|
||||
fake = FakeSocket(response)
|
||||
with patch("meteo.socket.create_connection", return_value=fake):
|
||||
registers = meteo.read_registers(CONFIG["modbus"], 20, 6)
|
||||
self.assertEqual(fake.sent, struct.pack(">HHHBBHH", 1, 0, 6, 8, 3, 20, 6))
|
||||
values, _ = meteo.decode(registers, 20, CONFIG["sensors"])
|
||||
for key, expected in {"temperature": -5.5, "humidity": 95.1, "pressure": 1020.2}.items():
|
||||
self.assertAlmostEqual(values[key], expected)
|
||||
|
||||
def test_modbus_exception(self):
|
||||
fake = FakeSocket(struct.pack(">HHHBBB", 1, 0, 3, 8, 0x83, 2))
|
||||
with patch("meteo.socket.create_connection", return_value=fake):
|
||||
with self.assertRaisesRegex(ValueError, "Wyjątek Modbus: 2"):
|
||||
meteo.read_registers(CONFIG["modbus"], 20, 6)
|
||||
|
||||
def test_closed_connection(self):
|
||||
with self.assertRaises(OSError):
|
||||
meteo.read_exact(FakeSocket(b"\x00"), 7)
|
||||
|
||||
def test_invalid_humidity(self):
|
||||
with self.assertRaisesRegex(ValueError, "humidity"):
|
||||
meteo.decode((73, 0, 0, 1001, 0, 10202), 20, CONFIG["sensors"])
|
||||
|
||||
def test_failure_retries_and_preserves_last_good(self):
|
||||
with patch("meteo.read_registers", side_effect=TimeoutError("test timeout")) as reader:
|
||||
with patch("meteo.time.sleep"):
|
||||
checked_at, values, raw, attempts, error = meteo.collect(CONFIG)
|
||||
self.assertEqual(reader.call_count, 3)
|
||||
self.assertIsNone(values)
|
||||
with tempfile.TemporaryDirectory(dir=meteo.BASE) as temporary:
|
||||
db = Path(temporary) / "test.sqlite3"
|
||||
good_time = "2026-10-12T10:00:00+00:00"
|
||||
meteo.save_result(db, good_time,
|
||||
{"temperature": 7.3, "humidity": 95.1, "pressure": 1020.2},
|
||||
{}, 1, None)
|
||||
last_good = meteo.save_result(db, checked_at, values, raw, attempts, error)
|
||||
status = meteo.write_status(Path(temporary) / "status.json", checked_at,
|
||||
values, last_good, CONFIG["timezone"])
|
||||
self.assertEqual(status["status"], "error")
|
||||
self.assertIsNone(status["values"])
|
||||
self.assertEqual(status["last_success_at"], good_time)
|
||||
with sqlite3.connect(db) as connection:
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT temperature_c,humidity_percent,pressure_hpa FROM readings WHERE status='error'"
|
||||
).fetchone(), (None, None, None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from test_support import config
|
||||
|
||||
from publish_lemmy import START, END, merge_body, render_section
|
||||
|
||||
|
||||
class PublishTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_config = patch("publish_lemmy.load_config", return_value=config())
|
||||
self.mock_config.start()
|
||||
self.addCleanup(self.mock_config.stop)
|
||||
def test_preserves_description(self):
|
||||
old = 'Opis użytkownika\n' + START + '\nstary pomiar\n' + END + '\nDalszy opis'
|
||||
self.assertEqual(merge_body(old, 'nowy pomiar'),
|
||||
'Opis użytkownika\n' + START + '\n\nnowy pomiar\n\n' + END + '\nDalszy opis')
|
||||
|
||||
def test_migrates_html_comments(self):
|
||||
text = 'Opis\n<!-- METEO:START -->\nstare\n<!-- METEO:END -->\nStopka'
|
||||
result = merge_body(text, 'nowe')
|
||||
self.assertEqual(result, 'Opis\n' + START + '\n\nnowe\n\n' + END + '\nStopka')
|
||||
self.assertNotIn('<!--', result)
|
||||
|
||||
def test_refuses_missing_duplicate_and_reversed_markers(self):
|
||||
for text in ('opis bez znaczników', START + START + END, END + START):
|
||||
with self.assertRaises(ValueError):
|
||||
merge_body(text, 'nowe')
|
||||
|
||||
def test_stale_measurement_and_failure_do_not_claim_healthy(self):
|
||||
status = {'status': 'ok', 'checked_at': '2026-10-13T00:00:00+00:00',
|
||||
'last_success_at': '2026-10-13T00:00:00+00:00',
|
||||
'values': {'temperature': 7.3, 'humidity': 95.1, 'pressure': 1020.3}}
|
||||
status['last_success_values'] = status['values']
|
||||
images = {name: {'url': '/fixture.png'}
|
||||
for name in ('temperatura', 'wilgotnosc', 'cisnienie')}
|
||||
fresh = render_section(status, images, datetime(2026, 10, 13, 0, 5, tzinfo=timezone.utc))
|
||||
self.assertIn('🟢 Działa poprawnie', fresh)
|
||||
self.assertIn('7,3 °C', fresh)
|
||||
self.assertEqual(fresh.count('![',), 3)
|
||||
old = render_section(status, images, datetime(2026, 10, 13, 3, tzinfo=timezone.utc))
|
||||
self.assertIn('🔴 Błąd stacji', old)
|
||||
self.assertIn('ostatniego poprawnego pomiaru.', old)
|
||||
status.update(status='error', values=None)
|
||||
failed = render_section(status, images, datetime(2026, 10, 13, 0, 5, tzinfo=timezone.utc))
|
||||
self.assertIn('🔴 Błąd stacji', failed)
|
||||
self.assertIn('7,3 °C', failed)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,10 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def config():
|
||||
c = json.loads((Path(__file__).parent / 'config.example.json').read_text(encoding='utf-8'))
|
||||
c['timezone'] = 'Europe/Warsaw'
|
||||
c['modbus'].update(host='localhost', unit_id=8)
|
||||
for key, address in [('temperature', 20), ('humidity', 23), ('pressure', 25)]:
|
||||
c['sensors'][key]['address'] = address
|
||||
return c
|
||||
Reference in new issue
Block a user