Az idő utazása

Tovább bonyolódik az óra fejlesztés, megérkeztek az időzónák

Úgy érzem magam, mint a Die Hard filmek magyar címeinek kitalálói érezhették magukat a negyedik rész környékén, amikor már nem nagyon tudtak több élet-szerű címmel előállni. Én is kezdek kifogyni az idővel kapcsolatos címekből, pedig ismét haladt előre egy kicsit a projekt.

Az előző részekben a CLion nem igazán szerette volna azt csinálni, amit mi szeretnénk és a PyCharm se volt a végletekig együttműködő ((Nincs még) Itt az idő), de legalább sikerült egy aranyos kis doboz-kezdeményt nyomtatni az óra köré (Dobozba zárt idő), amit most nem használunk.

Ma azzal folytatjuk, hogy visszalépünk egy kicsit és megpróbáljuk még egyszer megszelídíteni a CLion-t, azután pedig megtapasztalhatjuk azt is, hogy milyen borzalmakat rejtenek magukban az időzónák.

Fejlesztői környezet és a Pi

A hobbi projektek elsődleges célja (legalábbis nálam), hogy lehetőség szerint remekül szórakozzak (kinek mit jelent a szórakozás, ugye, de ezt majd később meglátjuk). Mivel foglalkozok eleget Python-nal a hétköznapok során, ezért szívesebben néztem volna más jellegű kihívások elé. Meg hát makacs is vagyok.

Az eredeti ötlet az volt, hogy fogok egy rendes Raspberry Pi-t, a Picoprobe (Debugprobe) helyett rákötöm a Pico-t a Raspberry Pi GPIO-jára, rádugok egy monitort/billentyűzetet/egeret és azon kezdek el fejleszteni. Az ötlet egy árnyalatnyit módosult, amikor nem találtam itthon megfelelő HDMI átalakítót a Rasberry Pi-hoz. Az új terv az lett, hogy megpróbálom meggyőzni a Windows gépen futó CLiont, hogy használja SSH-n keresztül a Raspberry Pi-t build környezetként.

Elméletileg a Pi a legalkalmasabb környezet a Pico fejlesztéshez. Néhány egyszerű parancs után mindennek működnie kell:

$ wget https://raw.githubusercontent.com/raspberrypi/pico-setup/master/pico_setup.sh
$ chmod +x pico_setup.sh
$ ./pico_setup.sh

Már csak egy kis várakozás és kész is... kivéve, ha véletlenül egy Raspberry Pi 5 egyre kevésbé boldog tulajdonosai vagyunk, mert arra a bejegyzés írásakor még nem volt felkészítve ez a script. Szerencsére megtaláltam ezt a pull request-et, ami a megoldás feléig eljuttatott, de szükségem volt még egy új OpenOCD interface konfigra is, amit már nem emlékszem honnan ollóztam össze:

/usr/local/share/openocd/scripts/interface/raspberrypi5.cfg
adapter driver linuxgpiod

adapter gpio swclk 24 -chip 4
adapter gpio swdio 23 -chip 4

Így már sikerült megfuttatni az egyik példaprogramot.

$ openocd -f interface/raspberrypi5.cfg -f target/rp2040.cfg -c "program gfx_pack_demo.elf verify reset exit"

Annak ellenére, hogy ezt sikerült megoldani, valamiért mégsem ebbe az irányba mentem, talán azt olvastam valahol, hogy ez a driver lassabb, mint a Picoprobe, úgyhogy visszadugdostam az eredeti felállást és USB-n kötöttem rá a Pi-ra a Pico-t, így az OpenOCD a már megszokott paranccsal működött:

$ openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg -c "program gfx_pack_demo.elf verify reset exit"

A munka oroszlánrésze

Már csak a CLiont kell rábírni az együttműködésre. Elméletileg megvan benne a támogatás, lássuk mire megyünk vele. Először is kell neki egy SSH konfig a Pi-hoz:

Aztán egy remote toolchain, ami ezt az SSH kapcsolatot használja:

Valószínűleg érdemes egy deployment-et is beállítani, hogy a fájl másolgatás jól működjön (ennek ellenére nem fog, de erre mindjárt visszatérünk):

És végül egy debug konfiguráció, hogy futtatni tudjuk a kódunkat.

Itt van egy pár érdekesség, amiről érdemes szót ejteni. A GDB-nél a remote host-on futót választottam, így a 'target remote' args csatlakozhat localhost-ra (a gdb és az openocd is a Pi-on fut). A "GDB Server args" környékén régen elég volt az első kettő paraméter (interface és target beállítás), az adapter speed valószínűleg opcionális. A program pedig... hát az elég érdekes.

Egy ideális világban az "Upload Executable" rész az mondjuk "If updated" lenne "Never" helyett, de ha az be van kapcsolva, akkor a Windows-os gép elérési útján próbálná megkeresni a fájlt, ami nincs ott, mert a Pi-on generálódott ELF fájlokat a CLion nem tölti le.

Ha kikapcsolom a ticket-ben említett beállítást és nyomok egy Tools > Resync with Remote Host opciót, akkor ugyan letölti az ELF fájlt, de valahogy még mindig nem kerül rá a Pico-ra. A kimenet alapján mintha elkezdené, de végül csak nem történik semmi. Vajon minek kell egyáltalán letöltenie (és aztán visszatöltenie a Pi-ra), ha minden ott van már a Pi-on? Rejtély.

Szóval inkább meghívom a program-ot kézzel, de így a reset miatt megszakad a debugger kapcsolata, ami a debug-olás esetén nem annyira kellemes. A vége az lett, hogy két konfigurációt csináltam, az egyik feltölteni tud (benne van a program rész), a másik meg csak debug-olni (nincs benne a program rész). Nem ideális a rendszer, de használható.

A konzolos kimenetet pedig a Pi-on futtatott minicom -b 115200 -o -D /dev/ttyACM0 paranccsal tudom elérni.

A pontos idő

A Python-os változatban elkészült már a pontos idő beszerzése, ezt kénytelenek vagyunk megtenni most a C SDK segítségével is. A valóságban ez még a Python-os változat előtt elkészült, de ettől most tekintsünk el.

Először is van egy kis konfigurációs része, ami a CMakeLists.txt-be kerül:

add_definitions(
  -DSNTP_SERVER_DNS=1
  -DSNTP_SERVER_ADDRESS="hu.pool.ntp.org"
  -DSNTP_SET_SYSTEM_TIME=sntp_set_system_time
  -DSNTP_STARTUP_DELAY=0
)

Amit itt érdemes megfigyelni, hogy hivatkozunk egy C-s függvényre (sntp_set_system_time), amit majd meg kell valósítanunk. Szükségünk lesz ugyanott pár könyvtárra is az SDK-ból:

target_link_libraries(
  pico-clock
  hardware_rtc
  pico_cyw43_arch_lwip_threadsafe_background
  pico_lwip_sntp
  pico_stdlib
  pico_time
)

A korábban említett függvény, ami kezeli az NTP szervertől kapott időt:

#include "hardware/rtc.h"
#include "lwip/apps/sntp.h"
#include "pico/cyw43_arch.h"
#include "pico/stdlib.h"
#include "pico/time.h"
#include "pico/util/datetime.h"

bool sntp_finished = false;

void sntp_set_system_time(uint32_t sec) {
  datetime_t datetime;
  time_to_datetime(sec, &datetime);

  rtc_set_datetime(&datetime);

  sntp_stop();
  cyw43_arch_disable_sta_mode();

  sntp_finished = true;
}

A sikeres idő szinkronizáció után kikapcsoljuk az SNTP modult és lejövünk a wifi-ről is. Már csak el kell indítanunk mindent:

int main() {
  stdio_init_all();
  cyw43_arch_init_with_country(CYW43_COUNTRY_HUNGARY);
  rtc_init();

  cyw43_arch_enable_sta_mode();
  while (cyw43_arch_wifi_connect_timeout_ms("SSID", "secret", CYW43_AUTH_WPA2_AES_PSK, 30000)) {
    printf("failed to connect.\n");
  }

  sntp_setoperatingmode(SNTP_OPMODE_POLL);
  sntp_init();

  while (!sntp_finished) {
    printf("waiting for time.\n");
    sleep_ms(100);
  }

  datetime_t now_utc;
  char datetime_buf[256];

  while (true) {
    rtc_get_datetime(&now_utc);
    datetime_to_str(&datetime_buf[0], sizeof(datetime_buf) / sizeof(char), &now_utc);
    printf("%s\n", datetime_buf);
    sleep_ms(1000);
  }
}

Ha minden jól megy, akkor a Pico kap internet elérést, felveszi a kapcsolatot az NTP szerverrel és a konzolon másodpercenként elkezdenek jönni az üzenetek a pontos időről:

Tuesday 27 August 20:47:13 2024
Tuesday 27 August 20:47:14 2024
Tuesday 27 August 20:47:15 2024
...

Nagy bánatunkra UTC-ben, úgyhogy kell valami megoldást találnunk rá, hogy a helyi időt megkapjuk.

Elidőztem az időzónákkal

Nem sokat érne egy asztali óra, ha csak UTC-ben lenne képes megjeleníteni az időt. Az NTP szervertől egy unix timestamp-et kapunk, de az RTC (Real-time clock) modul már egy datetime_t struct-ot vár és azt is tudunk kinyerni belőle, így az idő megjelenítésénél ezzel vagyunk kénytelenek dolgozni.

Elméletileg az mktime tud csinálni strukturált dátum és idő adatból unix timestamp-et és a localtime pedig unix timestamp alapján tud csinálni megfelelő időzónában lévő dátum és idő struct-ot. A pico/util/datetime.h ad néhány segédfüggvényt is, a későbbiekben ezeket fogjuk használni, de belül itt is az mktime és a localtime fut. A datetime_to_time egy datetime_t-ből csinál unix timestamp-et, a time_to_datetime pedig unix timestamp-ből csinál helyi időt (egy datetime_t-ben).

De van egy kis csavar. Mindkét irány helyi idővel dolgozik és meg kell tudnunk mondani az egyiknek, hogy UTC-ből dolgozzon, a másiknak pedig, hogy milyen időzónában vagyunk éppen. De hogyan is lehetne ezt beállítani?

Az egyetlen ötletem, amin el tudtam indulni az volt, hogy van a tzdata csomag, ami időzóna adatokat tartalmaz, hátha ott valamiből ki tudok indulni, mondjuk egy időzóna fájl tartalmát valahogy feljuttatni a Pico-ra. Nem ez vezetett a megoldáshoz, de megtudtam, hogy az időzóna meghatározásához használt pl. Europe/Budapest megnevezések tényleges fájlok a /usr/share/zoneinfo könyvtárban.

Összefutottam a leírásokban a TZ nevezetű környezeti változóval is, aminek az értéke lehet mondjuk Europe/Budapest, amiről most már tudjuk, hogy igazából a /usr/share/zoneinfo/Europe/Budapest fájl, amivel nem vagyunk előrébb.

Némi utánajárás után egy leírás alapján az is kiderült, hogy a TZ nem csak elérési utakat tartalmazhat, hanem egy az egyben időzóna definíciót is. A Europe/Budapest definíciója valahogy így néz ki a fenti leírás és a Wikipédia ide vonatkozó adatai alapján:

TZ=CET-1CEST,M3.5.0/2,M10.5.0/3

Az alap időzónánk a CET, amiből 1-et kell levonnunk, hogy megkapjuk az UTC-t. A nyári időszámításunk a CEST. A nyári időszámítás kezdete március utolsó vasárnapja (M3.5.0), hajnal 1 óra UTC szerint, ami a 2 órát jelenti CET-ben (mivel ebben a formátumban a helyi idő szerint kell megadnunk az órát). A nyári időszámítás vége október utolsó vasárnapja (M10.5.0), szintén hajnal 1 óra UTC szerint, ami 3 óra a CEST szerint.

Ha jól értem a dolgokat, akkor a fájl annyival tudna többet, hogy nem csak az aktuális szabály van benne, hanem visszamenőleg megvannak a régi szabályok is (vagy előremenőleg, ha valami szabály módosulás lesz a jövőben). Ha futtatunk mondjuk egy zdump -v Europe/Budapest | less parancsot, akkor látszik, hogy például 1916-ban még április utolsó vasárnapján este 11 órakor volt a nyári időszámításra váltás.

Így most már az RTC modultól kapott időt a megfelelő időzónára tudjuk alakítani, csak a TZ értéke legyen megfelelő az datetime_to_time és a time_to_datetime hívása előtt.

datetime_t now_utc;
datetime_t now_local_time;
char datetime_buf[256];

while (true) {
  rtc_get_datetime(&now_utc);

  setenv("TZ", "UTC", 1);
  time_t timestamp;
  datetime_to_time(&now_utc, &timestamp);

  setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
  time_to_datetime(timestamp, &now_local_time);

  datetime_to_str(&datetime_buf[0], sizeof(datetime_buf) / sizeof(char), &now_utc);
  printf("utc time:   %s\n", datetime_buf);
  datetime_to_str(&datetime_buf[0], sizeof(datetime_buf) / sizeof(char), &now_local_time);
  printf("local time: %s\n", datetime_buf);

  sleep_ms(1000);
}

Minden jónak tűnik... az első futásnál.

utc time:   Tuesday 27 August 21:02:03 2024
local time: Tuesday 27 August 23:02:03 2024
utc time:   Tuesday 27 August 21:02:04 2024
local time: Tuesday 27 August 21:02:04 2024
utc time:   Tuesday 27 August 21:02:05 2024
local time: Tuesday 27 August 21:02:05 2024

De csak az elsőnél, aztán gyorsan félremennek a dolgok. Mintha a két függvény valahogy egymás lábára lépne és elállítódnának a dolgok. Jó ideig nézegettem különböző implementációit a függvényeknek, de nem lettem tőle okosabb. Végül azzal a félmegoldással mentem, hogy a kernel kódjából hoztam el egy mktime implementációt, ami nem foglalkozik időzónákkal, így már működtek a dolgok.

Az igazi megvilágosodás akkor ért, amikor már ezt a bejegyzést írtam és újra elolvastam a TZ dokumentációját. Lehet, hogy csak az kell neki, hogy mennyi a különbség az UTC-hez képest még akkor is, ha UTC-t szeretnénk? Ez szerencsére megoldotta a problémát, a setenv("TZ", "UTC", 1)-et át kellett írni setenv("TZ", "UTC+0", 1)-ra. A sima UTC talán a /usr/share/zoneinfo/UTC fájlra vonatkozna, ami nekünk ugye itt nincs kéznél.

utc time:   Tuesday 27 August 21:08:56 2024
local time: Tuesday 27 August 23:08:56 2024
utc time:   Tuesday 27 August 21:08:57 2024
local time: Tuesday 27 August 23:08:57 2024
utc time:   Tuesday 27 August 21:08:58 2024
local time: Tuesday 27 August 23:08:58 2024
...

Minden szépen és jól működött, de úgy 10 perc után az óra ismét az UTC-s időt mutatta. Ezen a ponton azért már erősen megkérdőjelezhető, hogy vajon tényleg ez-e nekem a szórakozás.

Belevetettem magam a debug-olásba, de egy ponton túl már csak assembly kódot mutatott a CLion, valahonnan össze kellene szedni a forrást. Némi kutakodás után megtaláltam, hogy a Newlib nevezetű C könyvtárból jön többek között az mktime és a localtime is.

A Pi-on egy sudo apt install newlib-source parancs kiadásával megkaptam a forrást a /usr/src/newlib/newlib-3.3.0.tar.xz fájlban, amit aztán már be tudtam adni a CLion-nak. A dolgok mélyén végül azt találtam, hogy amikor a localtime megpróbálja betölteni a TZ környezeti változó értékét, akkor nem talál ott semmit. Ez elirányított a setenv irányába, ami nyilvánvalóan meg volt hívva ugyan, de a hatezredik futás környékén mintha már nem tette volna a dolgát.

Hosszas kutakodás után ennél a résznél kötöttem ki:

  if (!((*p_environ)[offset] =  /* name + `=' + value */
  _malloc_r (reent_ptr, (size_t) ((int) (C - name) + l_value + 2))))
    {
      ENV_UNLOCK;
      return -1;
    }

Le is ellenőriztem, a setenv hívás tényleg -1-et adott vissza, és bár nem vagyok egy C mágus, de a malloc-ból arra a következtetésre jutottam, hogy azért, mert elfogyott a memória. Készítettem egy rövid kis programot, hogy kipróbáljam:

int counter = 0;
int res;
while (true) {
  res = setenv("TZ", "UTC+0", 1);
  if (res == -1) {
    printf("baj van: %d\n", counter);
    break;
  }

  res = setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
  if (res == -1) {
    printf("baj van: %d\n", counter);
    break;
  }
  ++counter;
}

Ahogy azt sejteni lehetett, meg is kaptam a várt üzenetet:

baj van: 5217

Hiába nézegettem annyit az mktime-ot és a localtime-ot, a hiba végig a setenv-ben volt. Ha megnézzük a környező kódot, látszik, hogy ha az új környezeti változó értéke hosszabb, mint a régi érték volt, akkor új memóriaterületet foglal le neki, amit úgy tűnik nem szabadít fel senki se. A mi ciklusunk meg nagyjából másról se szól, mint hosszabb értékek beállításáról. Viszont ebből az következik, hogy ha ugyanolyan hosszúak, akkor nem lesz semmi baj, ugye? Feltöltöttem hát szóközökkel a rövidebbet.

int counter = 0;
int res;
while (true) {
  res = setenv("TZ", "UTC+0                       ", 1);
  if (res == -1) {
    printf("baj van: %d\n", counter);
    break;
  }

  res = setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
  if (res == -1) {
    printf("baj van: %d\n", counter);
    break;
  }
  ++counter;
}

Tény és való, ez már nem írja ki azt, hogy "baj van", megoldódott a probléma, visszarakom a kódba az idő konvertálgatást is, hogy lássam egyben működni...

utc time:   Tuesday 27 August 21:38:21 2024
local time: Tuesday 27 August 22:38:21 2024
utc time:   Tuesday 27 August 21:38:22 2024
local time: Tuesday 27 August 22:38:22 2024
utc time:   Tuesday 27 August 21:38:23 2024
local time: Tuesday 27 August 22:38:23 2024
...

Ez új. Valahogy elveszett egy óra. De nem estem kétségbe, ezen a ponton már elég rutint szedtem magamra, hogy sejtsem, a sok extra szóköz miatt nem tudja értelmezni rendesen az mktime a TZ értékét. Raktam egy extra vesszőt az UTC+0 után, hátha az meglágyítja a parser kő szívét...

setenv("TZ", "UTC+0,                      ", 1);

...és meglepő módon bevált:

utc time:   Tuesday 27 August 21:39:22 2024
local time: Tuesday 27 August 23:39:22 2024
utc time:   Tuesday 27 August 21:39:23 2024
local time: Tuesday 27 August 23:39:23 2024
utc time:   Tuesday 27 August 21:39:24 2024
local time: Tuesday 27 August 23:39:24 2024
...

Sikerült. Több napnyi szenvedés móka és kacagás után végül elértük, hogy a Pico óra a megfelelő helyi időt mutatja. Remélhetőleg a téli és nyári időszámítás közötti váltást is jól fogja kezelni. Majd október utolsó vasárnapján kiderül. Addigra remélhetőleg már az LCD kijelzőre is fogunk valamit rajzolni.

This post is also available in English: The time's traveling

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha kedveled a régi jó dolgokat.