You are currently viewing Bezobsługowe oświetlenie choinkowe

Bezobsługowe oświetlenie choinkowe

Dziś coś z gatunku „Adam Słodowy”. Zrobimy sterowanie lampkami choinkowymi tak, żeby zapalały się o zmierzchu i gasły w nocy, kiedy pójdziemy spać i nie będziemy ich podziwiać. W dzień nie będą świeciły, co pomoże ocalić planetę i podniesie respect factor.

Do wykonania zadania Adam Słodowy wziąłby deseczkę, kilka gwoździ i młotek. Nasze zadanie także będzie wymagać paru fizycznych detali i zdolności majsterkowania. Tym razem sam kod nie wystarczy. Będziemy potrzebowali: Raspberry Pi, kilku przewodów. Konieczny też będzie przekaźnik, konwerter napięcia i lampki choinkowe. Przyda się też choinka, ale to dopiero na końcu. Zamiast młotka weź lutownicę. Tymczasem zakładam, że jeśli masz malinę, to pozostałe gadżety też znajdziesz w szufladzie.

Hardware

Potrzebny sprzęt:

  • Rpi4/5
  • Konwerter napięcia TXS0108E
  • Moduł przekaźnikowy
  • Zegar czasu rzeczywistego RTC
  • Lampki choinkowe i choinka

Elementy łączymy jak na schemacie. Zegar czasu rzeczywistego przyda się, żeby malina nie zgubiła czasu w przypadku chwilowej przerwy w zasilaniu i przy braku Internetu. Jest to element opcjonalny i nie będę tu opisywał, jak go skonfigurować z maliną. Opisów jest dużo a procedura prosta. Raspbian wspiera RTC „z pudełka”.

Konwerter napięcia zapewni współpracę maliny (wejścia/wyjścia pracują z napięciem 3,3V, a element wykonawczy – przekaźnik jest zasilany napięciem 5V. Teoretycznie stan wysoki wyjścia maliny 3,3V powinien być powyżej progu stanu wysokiego takiego modułu (pewnie ok. 2,4V) ale jeśli chcemy mieć pewność, że to zadziała, to konwerter nie zaszkodzi. Jest jeszcze jeden powód stosowanie konwertera. W przypadku przypadkowego podania na wejście napięcia 5V nawet na chwilę, mamy prawie na pewno usmażony obwód wejściowy takiego wejścia. Nie wnikając teraz w elektronikę, powiem tylko, że mocno zalecam stosowanie konwertera.

Podłączenie obciążenia do modułu przekaźnikowego nie nastręczy Ci trudności, ani nie wymaga uprawnień SEP, ale zalecam ostrożność i stosowanie zasad bezpieczeństwa, jak przy każdej pracy z napięciem sieciowym.

Software

Program w C# będzie sterowany zdarzeniami. Składa się z podstawowego modułu zrealizowanego jako BackgroundService, który nasłuchuje, czy wystąpiło zdarzenie które ma włączyć lub wyłączyć przekaźnik. Skoro coś nasłuchuje, to należy także utworzyć moduły generujące zdarzenia. Potrzebne będą dwa zdarzenia: nadchodzi zmierzch (włącz lampki) oraz idę spać o 23.00 (wyłącz). Oba moduły generujące zdarzenia także będą BackgroundService’ami. Konieczny też będzie moduł sterujący fizycznymi wyjściami maliny. Wszystkie elementy omówimy po kolei. Na koniec zostanie wygenerowany kod dla maliny z gotowej aplikacji utrorzymy serwis systemowy.

Na początek utwórz projekt konsolowy i dodaj pakiety nuget potrzebne do zbudowania hosta. Dodamy też logger. Po wykonaniu pierwszego polecenia zmień folder na /choinka i wykonaj kolejne polecenia.

dotnet new console -n choinka
dotnet add package System.Device.Gpio --version 4.0.1
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.Abstractions
dotnet add package Serilog

Następnie utwórz host, który za chwilę będziemy wypełniać treścią

using choinka.Triggers.SolarTime;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;

try
{
    var hostBuilder= Host.CreateDefaultBuilder();
    hostBuilder.Build().Run();
}
catch (Exception ex)
{
    Log.Fatal("Fatar error: {Message}", ex.Message);
}
finally
{
    Log.Information("Shutdown complete");
    Log.CloseAndFlush();
}

Zegar astronomiczny

Lampki będziemy zapalać o zachodzie słońca. Nie ma sensu ustalać sztywnej godziny, bo w zimie moment zapadania ciemności zmienia się bardzo dynamicznie. Na przyklad. już w 10 dni po przesileniu, słońce zachodzi ok. 30 minut później. Słowem, nie ma co się męczyć i nieustannie przetawiać czas włączenia lampek, Niech się dzieje samo! Wykorzystamy bibliotekę SolarCalculator.

dotnet add package SolarCalculator

Niech godzina zachodu odpowiada lokalizacji geograficznej. Solar Calculator obliczy zachód słońca w oparciu o podany czas i lokalizację (koordynaty geograficzne). Dodajmy miejca. Dla mnie wystarczą Gdańsk i Warszawa. Ty dodaj swoje:

public class Places
{
    public IEnumerable<Coordinates> Coordinates { get; init; } = [
        new Coordinates()
        {
            Name = "Warsaw", Latitude = 52.2298, Longitude = 21.0117
        },
        new Coordinates()
        {
            Name = "Gdansk", Latitude = 54.35, Longitude = 18.6667
        },
    ];
}

public class Coordinates
{
    public string Name { get; init; } = null!;
    public Angle Latitude { get; init; } = Angle.Empty;
    public Angle Longitude { get; init; } = Angle.Empty;
}

Potem dodamy Places do kontenera DI, bo za chwilę wstrzykniem je do serwisu. Ta akurat choinka będzie w Warszawie. Wobec tego pobieram koordynaty Warszawy w konstruktorze. Ty pobierz swoje koordynaty, które wcześniej zdefiniowałeś. Kalkulator może policzyć czasy wystąpienie bardzo różnych zjawisk astronomicznych. Ja potrzebuję czas zachodu słońca.

internal class SolarCalculator : ISolarCalculator
{
    private readonly Coordinates _warsaw;
    private readonly Places _places;

    public SolarCalculator(Places places)
    {
        _places = places;
        _warsaw = _places.Coordinates.First(c => c.Name.Equals(
            "Warsaw", StringComparison.Ordinal));
    }

    public DateTime GetWarsawSunset(DateTimeOffset? date = null)
    {
        var time = new SolarTimes(date ?? DateTimeOffset.Now, _warsaw.Latitude, _warsaw.Longitude);
        return time.Sunset;
    }
}

internal interface ISolarCalculator
{
    DateTime GetWarsawSunset(DateTimeOffset? date = null);
}

Teraz przychodzi czas na nieco ciekawszy kawałek kodu. Będzie to BackgroundService sprawdzający każdego dnia czy nadszedł zachód słońca. W odpowiednim momencie wywoła on event, którego obsługa będzie polegała na włączeniu lampek. Ten kawałek kodu objaśnię nieco szerzej już niedługo.

Solar Service

Zawiera dwa istotne elementy: Jeden to `event EventHandler SunsetOccurred` czyli zdarzenie, na które aplikacja zareaguje włączając lampki. Drugi element to logika obliczająca czas zachodu słońca i śledząca czy ten czas już nadszedł. Jest ona zaszyta w metodzie ExecuteAsync serwisu działającego w tle BackgroundService. ExecuteAsync uruchamia Task, który na początek bada, czy aplikacja nie została uruchomiona już po zachodzie słońca. W takim przypadku wywołuje zdarzenie SunsetOccurred. Po sprawdzeniu przechodzi do nieskończonej pętli (linia 52), w której, podobnie jak wcześniej, oblicza moment zachodu (ale już kolejnego dnia), oblicza też ile czasu zostało do tego zachodu, po czym przechodzi w uśpienie (linia 63). Kiedy czas minie event jest wywoływany pod warunkiem, że proces nie jest zamykany (linia 46). Dokładnie to CancellationToken przekazywany do metody ExecuteAsync wchodzi w stan canceled, kiedy wywołana jest metoda StopAsync BackgroundService’u. A StopAsync jest wołana, kiedy host otrzyma wezwanie do zatrzymania od systemu operacyjnego. Na jedno wychodzi, ale warto być dokładnym 😉

Zwróć uwagę na to, że pobranie czasu nie jest zakodowane w tasku RunEventLoopAsync, funkcja obliczająca jest parametrem wywołania (linia 16). W ten sposób łatwiej wprowadzać zmiany, albo dodawać kolejne taski eventLoop (np. SunriseOccurred). Będą one miały analogiczną strukturę. Należy tylko w wywołaniu podać inny delegat Func<> i string reason. Zwróć też uwagę, że każdy handler jest wywoływany w oddzielnym bloku try/catch (linia 128). To chroni aplikację przed błędami, jakie mogłyby wystąpić w metodzie obsługi zdarzenia (handlerze). Normalnie, po błędzie, kolejne handlery zasubskrybowane do zdarzenia nie byłyby wywołane. Warto zapamiętać ten wzorzec.

Kod obsługuje wyjątki OperationCanceledException, które będą rzucane przy zamykaniu aplikacji. Osobiście uważam, że ten wyjątek zawsze powinien być obsłużony i kiedy zamykamy aplikację, to nie powinny temu towarzyszyć wyjątki w logach. Mała rzecz, a cieszy 🙂

internal class SolarNotifierService(
    ILogger<SolarNotifierService> logger,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    public event EventHandler SunsetOccurred;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await RunEventLoopAsync(
            "Zachód słońca",
            (calc, date) => calc.GetWarsawSunset(date),
            reason => InvokeSunsetEvent(reason),
            stoppingToken);
    }

    private async Task RunEventLoopAsync(
        string eventName,
        Func<ISolarCalculator, DateTimeOffset?, DateTime> getEventTime,
        Action<string> invokeEvent,
        CancellationToken token)
    {
        try
        {
            // Initial check + possible catch-up
            using (var scope = scopeFactory.CreateScope())
            {
                var calculator = scope.ServiceProvider.GetRequiredService<ISolarCalculator>();
                var todayTime = getEventTime(calculator, null);
                var now = DateTimeOffset.Now;

                if (now > todayTime)
                {
                    logger.LogWarning("Aplikacja uruchomiona po {EventName}. Wywołuję event natychmiast (catch-up).", eventName);
                    invokeEvent($"Zaległy {eventName} (start aplikacji po czasie)");
                }
                else
                {
                    logger.LogInformation("Czekam na dzisiejszy {EventName}: {EventTime}", eventName, todayTime);
                    await WaitUntil(todayTime, token);

                    if (!token.IsCancellationRequested)
                        invokeEvent($"Dzisiejszy {eventName}");
                }
            }

            // Daily loop
            while (!token.IsCancellationRequested)
            {
                try
                {
                    var tomorrow = DateTimeOffset.Now.AddDays(1);
                    using var scope = scopeFactory.CreateScope();
                    var calculator = scope.ServiceProvider.GetRequiredService<ISolarCalculator>();
                    var nextTime = getEventTime(calculator, tomorrow);

                    logger.LogInformation("Następny {EventName} zaplanowany na: {EventTime}", eventName, nextTime);

                    await WaitUntil(nextTime, token);

                    if (!token.IsCancellationRequested)
                        invokeEvent($"Planowy {eventName}");
                }
                catch (OperationCanceledException)
                {
                    // cancellation requested - exit loop
                    break;
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Unexpected error in {EventName} loop", eventName);
                    try
                    {
                        await Task.Delay(TimeSpan.FromSeconds(5), token);
                    }
                    catch (OperationCanceledException)
                    {
                        break;
                    }
                }
            }
        }
        catch (OperationCanceledException)
        {
            // cancelled before starting - ignore
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Fatal error while starting {EventName} loop", eventName);
        }
    }

    private async Task WaitUntil(DateTime targetTime, CancellationToken token)
    {
        var delay = targetTime - DateTime.Now;
        if (delay.TotalMilliseconds > 0)
        {
            try
            {
                await Task.Delay(delay, token);
            }
            catch (TaskCanceledException)
            {
                logger.LogWarning("Task cancelled");
                // ignore
            }
        }
    }

    private void InvokeSunsetEvent(string reason)
    {
        logger.LogInformation("EVENT: ZACHÓD SŁOŃCA ({reason})", reason);
        SafeInvoke(SunsetOccurred, EventArgs.Empty, "SunsetOccurred");
    }

    private void SafeInvoke(EventHandler? handler, EventArgs args, string eventName)
    {
        if (handler == null)
            return;

        var invocationList = handler.GetInvocationList();
        foreach (var @delegate in invocationList)
        {
            try
            {
                if (@delegate is EventHandler eventHandler)
                    eventHandler(this, args);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Exception thrown by handler for {EventName}; continuing with other handlers", eventName);
            }
        }
    }
}

Wschód słońca

Wspomniałem. że SolarNotifierService może obsługiwać więcej zdarzeń. Gdybyśmy chcieli coś zrobić np. o świcie, to wystarczy zdefiniować kolejne zdarzenie i uruchomić task realizujący logikę jego wywoływania. Zamiast linii 10-18 mielibyśmy coś takiego:

    public event EventHandler SunriseOccurred;
    public event EventHandler SunsetOccurred;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var sunriseTask = RunEventLoopAsync(
            "Wschód słońca",
            (calc, date) => calc.GetWarsawSunrise(date),
            reason => InvokeSunriseEvent(reason),
            stoppingToken);

        var sunsetTask = RunEventLoopAsync(
            "Zachód słońca",
            (calc, date) => calc.GetWarsawSunset(date),
            reason => InvokeSunsetEvent(reason),
            stoppingToken);

        await Task.WhenAll(sunriseTask, sunsetTask);

Oczywiście należałoby dodać definicję sunriseTask w sposób, który opisałem wcześniej. Trzeba też rozszerzyć interfejs ISolarCalculator o medodę GetWarsawSunrise i ją zaimplementować. Mając jednak wzorzec, to już pestka.

Alarm Clock Service

Żeby wyłączyć lampki potrzebujemy zdarzenia, że nadeszła 23:00. To jest zrealizowane analogicznie, jak w usłudze obsługującej zachód słońca. BackgroundService zawiera publiczny event EventHandler? Alarm2300Triggered, do którego za chwilę zasubskrybujemy procedurę wyłączenia prądu. Serwis zawiera także task alarm2300Task uruchamiany przy jego starcie. Po szczegóły odsyłam do kodu na githubie.

GpioController

Teraz wypadało by obsłużyć zdarzenia i spowodować zapalanie lampek, Normalnie użylibyśmy bezpośrednio sterownika GpioController pochodzącego z pakietu System.Device.Gpio. Ma on jednak pewną cechę, którą chcę zmodyfikować. Otóż twórcy biblioteki zakładają, że aplikacja może uruchomić GpioController, ustawić jakiś stan i zakończyć pracę. Przy czym konfiguracja i stan wyjścia pozostaje taki jaki został ustawiony. Nie jest przywracany stan „spoczynkowy”. Jest to pożądane przy np. zadaniach uruchamianych wg harmonogramu. Manipuluje się wtedy stanem wyjść i kończy pracę. Jednak w tym przypadku chcę, aby to działało inaczej. Chodzi o to, by kończąc pracę, aplikacja zmieniła stan na zdefiniowany jako „spoczynkowy*. Ma to zapobiec sytuacji, polegającej na tym, że jeśli między zachodzem słońca a 23:00 aplikacja lub system operacyjny zostaną zamknięte, to lampki pozostaną zapalone po 23:00.

Stan wyjścia GPIO

Muszę zdefiniować i utrzymać stan wyjścia w aplikacji. Będzie on trzymany w klasie PinState.

public class PinState(GpioPin pin, PinValue onClose, PinValue? value)
{
    public static PinState CreateState(GpioPin pin, PinValue onCloseValue, PinValue? value) =>
        new(pin, onCloseValue, value);

    public GpioPin Pin { get; set; } = pin;
    public PinValue OnCloseValue { get; set; } = onClose;
    public PinValue? Value { get; set; } = value;
}

GpioControllerWithPinRestore

Stan będzie ustawiany w kontrolerze GpioControllerWithPinRestore, który wyłączy prąd i zdeaktywuje konwerter napięcia przy zamykaniu aplikacji. W tym celu rozszerzam GpioController.

Konstruktor jest „DI friendly” i przyjmuje wstrzyknięty Ilogger. Ten konstruktor zostanie wybrany przez kontener DI przy instancjonowaniu obiektu. .NET zawsze wybiera konstruktor (o ile ma wybór) który pozwoli na wstrztyknięcie jak największej liczby zarejestrowanych serwisów. Oprócz tego jest konstruktor bezparametrowy, który się przyda w aplikacji bez kontenera DI.

Przed użyciem wyjścia, trzeba wywołać OpenPin() (linia 14) i podać stan wyjcia, jaki chcemy mieć po zamknięciu aplikacji (onCloseValue). Jeśli tego nie zrobisz, to próba wykonania Write() (linia 21) rzuci wyjątek z bazowego GpioController.

Kontener DI przy zamykaniu aplikacji woła Dispose() (linia 34) dla wszystkich serwisów, które implenetują IDisposable. W tym momencie wyjścia zostaną ustawione w stan onCloseValue. Lampki zgasną. Sporo roboty, żeby zgasić światło 😉

public class GpioControllerWithPinRestore : GpioController, IDisposable
{
    private readonly ConcurrentDictionary<int, PinState?> _pins = [];
    private readonly ILogger<GpioControllerWithPinRestore>? _logger;

    public GpioControllerWithPinRestore(ILogger<GpioControllerWithPinRestore> logger) : base()
    {
        _logger = logger;
    }

    public GpioControllerWithPinRestore() : base()
    { }

    public GpioPin OpenPin(int pinNumber, PinMode mode, PinValue initialValue, PinValue onCloseValue)
    {
        var pin = base.OpenPin(pinNumber, mode, initialValue);
        _pins.TryAdd(pinNumber, PinState.CreateState(pin, onCloseValue, initialValue));
        return pin;
    }

    public new void Write(int pinNumber, PinValue value)
    {
        var isPinExisting = _pins.TryGetValue(pinNumber, out var pinState);
        if (isPinExisting)
        {
            pinState!.Value = value;
            _pins[pinNumber] = pinState;
        }
        base.Write(pinNumber, value);
    }

    // other commands omitted for brevity

    public new void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    public new void Dispose(bool disposing)
    {
        foreach (var pin in _pins)
        {
            try
            {
                Write(pin.Key, pin.Value.OnCloseValue);
            }
            catch (Exception)
            {
                // ignore
            }
        }
        base.Dispose(disposing);
    }
}

Prawie wszystkie składowe są gotowe. Czas na ostatni, który je powiąże w funkcjonalną całość.

Główny serwis GpioWorker

Bardzo ważny, ale najprostszy z dotychczasowych serwis robi niewiele. Jego zadaniem jest zasubskrybowanie handlerów (tak, to te obrzydliwe metody void lub nawet gorzej async void) do zdarzeń. Na tym mógłby zakończyć pracę. Musi jednak być utrzymany przy życiu po to, żeby odśmiecacz Garbage collector nie usunął instancji a wraz z nią event handlerów trzymających referencje do metod obsługi.

Dobra praktyka nakazuje usunąć z listy subskrybcyjnej nieużywane handlery. To się dzieje w metodzie StopAsync() wołanej przez host przy jego zamykaniu.

internal class GpioWorker(
    ILogger<GpioWorker> logger,
    GpioControllerWithPinRestore gpioController,
    SolarNotifierService solarNotifier,
    AlarmClockService alarmClockService) : BackgroundService
{
    const int _pinChoinka= 26;
    const int _pinLevelConverter= 6;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            // subscribe to notifications and ensure level converter is enabled
            solarNotifier.SunsetOccurred += Choinka_OnEventOccurred;
            alarmClockService.Alarm2300Triggered += Choinka_OffEventOccurred;
       
            gpioController.OpenPin(_pinLevelConverter, PinMode.Output, PinValue.High, PinValue.Low);
            if (gpioController.Read(_pinLevelConverter) == PinValue.Low)
                gpioController.Write(_pinLevelConverter, PinValue.High);

            await Task.Delay(Timeout.Infinite, stoppingToken);
        }
        catch (OperationCanceledException)
        {
            logger.LogInformation("GpioWorker cancellation requested");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error initializing GpioWorker. In case of error 13, try to elevate privileges and run the application with 'sudo'.");
        }
    }

    public override Task StopAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("GpioWorker stopping, unsubscribing events and cleaning up pins");

        try
        {
            solarNotifier.SunsetOccurred -= Choinka_OnEventOccurred;
            alarmClockService.Alarm2300Triggered -= Choinka_OffEventOccurred;
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Error while stopping GpioWorker");
        }

        return base.StopAsync(cancellationToken);
    }

    private void Choinka_OnEventOccurred(object? sender, EventArgs e)
    {
        if(!gpioController.IsPinOpen(_pinLevelConverter))
            gpioController.OpenPin(_pinLevelConverter, PinMode.Output, PinValue.High, PinValue.Low);

        if (gpioController.Read(_pinLevelConverter) == PinValue.Low)
            gpioController.Write(_pinLevelConverter, PinValue.High);

        if (!gpioController.IsPinOpen(_pinChoinka))
            gpioController.OpenPin(_pinChoinka, PinMode.Output, PinValue.Low, PinValue.Low);

        if (gpioController.Read(_pinChoinka) == PinValue.Low)
            gpioController.Write(_pinChoinka, PinValue.High);
    }

    private void Choinka_OffEventOccurred(object? sender, EventArgs e)
    {

        if (!gpioController.IsPinOpen(_pinChoinka))
            gpioController.OpenPin(_pinChoinka, PinMode.Output, PinValue.Low, PinValue.Low);
        else
            gpioController.Write(_pinChoinka, PinValue.Low);

        logger.LogInformation("Stan wyjścia: {StanChoinka}", gpioController.Read(_pinChoinka));
    }
}

Rejestracja usług

W Program.cs przed linią 9 (z pierwszego listingu) dodajemy sekcję rejestrującą serwisy. To, co tu może zwróić uwagę, to sposób rejestracji usług tła BackgroundService. Nie rejestruję ich najpopularniejszym sposobem AddHostedService<T>(), tylko rejestuję singleton (którym i tak są), ale używam przeciążenia biorącego funkcię AddHostedService(Func<IServiceProvider, T> func). Tak mam zarejestowany singleton, który mogę wstrzyknąć do GpioWorker, i jednocześnie uruchamiam serwis w tle. To jest jeden z alternatywnych sposobów rejestracji. Przy okazji przestrzegam przed pomysłem typu AddHostedService(sp => new AlarmClockService()). To możę i zadziała pozornie (po dodaniu konstruktora bezparametrowego, ale kontener DI nie będzie zarządzał cyklem życia takiego obiektu i może to mieć nie przewidziane konsekwencje.

    hostBuilder.ConfigureServices((ctx, services) =>
    {
        services.AddSingleton(sp => new Places());
        // sun time services
        services.AddSingleton(sp => new Places());
        services.AddScoped<ISolarCalculator, SolarCalculator>();
        services.AddSingleton<SolarNotifierService>();
        services.AddHostedService(sp => sp.GetRequiredService<SolarNotifierService>());
        // timed event service
        services.AddSingleton<AlarmClockService>();
        services.AddHostedService(sp => sp.GetRequiredService<AlarmClockService>());

        services.AddSingleton<GpioControllerWithPinRestore>();
        services.AddHostedService<GpioWorker>();
    });

Usługa systemu Linux

Raspian (system operacyjny RaspberryPi) pochodzi od Ubuntu i jest systemem linuxowym. Wygodnie będzie wdrożyć lampli jako usuługę systemową. To daje wygodę polegającą m.in na tym, że usługa sama wstaje razem z systemem.

cdn..

Dodaj komentarz