Release v1.0.0: configurable Modbus weather station publisher for Lemmy

This commit is contained in:
adamzend committed 2026-10-13 13:03:34 +02:00
commit 4153cd7361
21 files changed
+1632

No files matched your search

+17
View File
@@ -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
+85
View File
@@ -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.
+218
View File
@@ -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.
![The live Szymbark weather post showing temperature, humidity, pressure, station status and monthly charts](docs/images/live-weather-post.jpg)
**Lemmy instance homepage:** [szymbark.net](http://szymbark.net)
+197
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
1.0.0
+165
View File
@@ -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))
+51
View File
@@ -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
View File
@@ -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.'
+51
View File
@@ -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()
+183
View File
@@ -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())
+229
View File
@@ -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"![{title} — bieżący miesiąc]({images[slug]['url']})"])
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())
+11
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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))
+5
View File
@@ -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. |
+68
View File
@@ -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()
+89
View File
@@ -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()
+90
View File
@@ -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()
+51
View File
@@ -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()
+10
View File
@@ -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