Popularny post H1M4W4R1 Napisano Wrzesień 25, 2024 Popularny post Udostępnij Napisano Wrzesień 25, 2024 Intermediate Resource Integration System (IRIS2) IRIS to projekt, który leżał u mnie od dłuższego czasu - co jakiś czas projektuję urządzenia podłączane do komputera, ale zawsze ciężko dorobić do nich oprogramowanie, bo co urządzenie to zwykle inaczej się one komunikują (inne pakiety danych etc.) Rolą IRIS jest "uprościć" (może lepszym określeniem było ułatwić analizę kodu przez AI) ten proces maksymalnie jak tylko się da, a że moją domeną jest C# to padło też na wybór tego języka programowania. Przykładowe użycie IRIS /// <summary> /// Represents an example of a device that changes or reads the value of an LED /// using RUSTIC protocol messages. /// </summary> public sealed class ExampleArduinoLEDChangingDevice(SerialPortDeviceAddress deviceAddress, SerialInterfaceSettings settings) : SerialDeviceBase(deviceAddress, settings) { /// <summary> /// Get LED value /// </summary> public async Task<bool> GetLEDValue(CancellationToken cancellationToken = default) => await GetLEDValue<GetValueTransaction>(cancellationToken); private async Task<bool> GetLEDValue<TTransactionType>(CancellationToken cancellationToken = default) where TTransactionType : IDataExchangeTransaction<GetValueTransaction, GetValueRequestData, GetValueResponseData> { // Create request data GetValueRequestData requestData = new("LED"); // Exchange data GetValueResponseData result = await TTransactionType .ExchangeAsync<ExampleArduinoLEDChangingDevice, SerialPortInterface>(this, requestData, cancellationToken); // Return result of the operation return result.value.ToString() == "1"; } /// <summary> /// Set LED value /// </summary> public async Task<bool> SetLEDValue(bool value, CancellationToken cancellationToken = default) => await SetLEDValue<SetValueTransaction>(value, cancellationToken); private async Task<bool> SetLEDValue<TTransactionType>(bool value, CancellationToken cancellationToken = default) where TTransactionType : IDataExchangeTransaction<SetValueTransaction, SetValueRequestData, SetValueResponseData> { // Create request data SetValueRequestData requestData = new("LED", value ? "1" : "0"); // Exchange data SetValueResponseData result = await TTransactionType .ExchangeAsync<ExampleArduinoLEDChangingDevice, SerialPortInterface>(this, requestData, cancellationToken); // Return result of the operation return !result.IsError; } } Przykład korzysta z wbudowanego protokołu, który nazwałem RUSTIC. Protokół ten opiera się na prostych przypisaniach wartości przesyłanych jako linijki poprzez UART np. // Przypisanie zmiennej > ZMIENNA=32 < ZMIENNA=OK // Pobranie wartości zmiennej > ZMIENNA=? < ZMIENNA=32 Oprócz protokołu RUSTIC standardowo jest też zaimplementowany protokół LINE, który jest prostym protokołem wymiany wiadomości (np. do zaimplementowania w formie konsoli do debugowania). /// <summary> /// Read message from device /// </summary> public async Task<string> ReadMessage() => await ReadMessage<LineReadTransaction>(); private async Task<string> ReadMessage<TTransaction>() where TTransaction : IReadTransaction<TTransaction, LineTransactionData>, new() { // Read message LineTransactionData response = await TTransaction.ReadAsync<ExampleArduinoEchoDevice, SerialPortInterface>(this); // Return response return response.ToString(); } Oraz przykład użycia samego urządzenia w docelowej aplikacji: public static class ExampleApp { public static async void RunApp(string comPort) { // Create new Arduino echo device ExampleArduinoLEDChangingDevice device = new(new SerialPortDeviceAddress(comPort), new SerialInterfaceSettings(115200)); device.Connect(); // Exchange data example bool ledValue = await device.GetLEDValue(); Console.WriteLine("LED value: " + ledValue); // Wait for 500 ms to see the result await Task.Delay(500); // Set LED value bool setOk = await device.SetLEDValue(true); Console.WriteLine("Set LED value was success: " + setOk); // Read LED value ledValue = await device.GetLEDValue(); Console.WriteLine("LED value after set: " + ledValue); // Wait for 500 ms to see the result await Task.Delay(500); // Set LED value setOk = await device.SetLEDValue(false); Console.WriteLine("Set LED value was success: " + setOk); // Read LED value ledValue = await device.GetLEDValue(); Console.WriteLine("LED value after set: " + ledValue); // Disconnect device device.Disconnect(); } } Ale jak to działa? Podstawowym elementem IRIS jest klasa DeviceBase, która reprezentuje urządzenie (zazwyczaj sprzęt podłączony do komputera, ale nie jest to twarde wymaganie, równie dobrze urządzeniem może być API REST, ale to nadużywanie elastyczności projektu). Urządzenie to punkt dostępu dla zewnętrznych aplikacji, które będą komunikować się ze sprzętem (nie powinny one używać funkcji niskiego poziomu, aczkolwiek są one dostępne do bardziej radykalnych zastosowań). Urządzenie wykonuje Transakcje - wymianę danych pomiędzy komputerem, a sprzętem do niego podłączonym. Rolą transakcji jest wymiana pakietów danych, najprościej można sprowadzić transakcję do komendy, aczkolwiek jest to porównanie dość rozbieżne z możliwościami, gdyż jedna transakcja może również reprezentować serię komend wykonywanych w urządzeniu. Transakcje zawierają opis "jak" interfejs komunikacyjny powinien wykonać transmisję danych (odczyt/zapis) do urządzenia. Przykładowa trasakcja odczytania tekstu z urządzenia: public struct LineReadTransaction : IReadTransaction<LineReadTransaction, LineTransactionData>, ILineTransaction { public async Task<LineTransactionData> _ReadAsync<TDevice, TCommunicationInterface>( TDevice device, CancellationToken cancellationToken = default) where TDevice : DeviceBase<TCommunicationInterface> where TCommunicationInterface : ICommunicationInterface { // Get the communication interface and receive data ICommunicationInterface communicationInterface = device.GetCommunicationInterface(); return await communicationInterface .ReceiveDataAsync<LineReadTransaction, LineTransactionData>(this, cancellationToken); } } Transakcja ta implementuje interfejs IReadTransaction, który informuje iż jest to transakcja odczytu. Pierwszym parametrem generycznym (osoby zaznajomione z C++ mogą używać określenia template') jest typ transakcji (tzw. TSelf), a drugim jest typ zwracanej struktury danych. Transakcja wykorzystuje wbudowaną implementację odbioru danych z urządzenia poprzez jego interfejs komunikacyjny (zazwyczaj port szeregowy). Dodatkowo transakcja implementuje interfejs ILineTransaction, który zawiera kilka istotnych informacji: public interface ILineTransaction : ITransactionReadUntilByte, IWithEncoder<LineDataEncoder, byte[]> { byte ITransactionReadUntilByte.ExpectedByte => 0x0A; } Informuje on, iż pakiet danych kończy się zawsze bajtem 0x0A oraz, że do konwersji danych binarnych do struktur wykorzystywany jest konkretny Enkoder/Dekoder. By nie zagłębiać się za bardzo w szczegóły - niektóre interfejsy typu port szeregowy (w tym ten podłączony przez USB) używają danych binarnych do transmisji. Takie interfejsy nie wykorzystują standardowej implementacji interfejsu komunikacyjnego, tylko implementację rozszerzoną przeznaczoną dla interfejsów operujących danymi surowymi. Ta implementacja posiada wbudowane metody do wykrywania i dostosowywania się do wymagań danej transakcji - potrafią wykryć jaki enkoder jest w danej transakcji zaimplementowany i automatycznie użyć go do konwersji danych. Dla chętnych implementacja jest przedstawiona poniżej: async Task<TResponseDataType> ICommunicationInterface.ReceiveDataAsync<TTransactionType, TResponseDataType>( TTransactionType transaction, CancellationToken cancellationToken) { // Check if transaction supports data reader, if not throw exception if (transaction is not IWithDataReader withDataReader) throw new NotSupportedException("Transaction type is not supported"); // Read data using raw data reader byte[] data = await withDataReader .ReadDataAsync<IRawDataCommunicationInterface, TTransactionType, IRawDataReader, byte[]>(this, transaction, cancellationToken); // Decode data return await DecodeData<TResponseDataType, TTransactionType>(transaction, data); } private Task<TResponseDataType> DecodeData<TResponseDataType, TTransactionType>( TTransactionType transaction, byte[] rawData) where TResponseDataType : struct { // Check if user expects raw data and convert it if needed if (typeof(TResponseDataType) == typeof(byte[])) return Task.FromResult((TResponseDataType) Convert.ChangeType(rawData, typeof(TResponseDataType))); // Check if transaction supports encoder, if not throw exception if (transaction is not IWithEncoder withEncoder) throw new NotSupportedException("Transaction does not support encoder. Cannot decode data."); // Decode data using encoder withEncoder.Decode(rawData, out TResponseDataType responseData); return Task.FromResult(responseData); } Czytaki IRIS posiada też kilka bardziej zaawansowanych możliwości. Jedną widać w poprzedniej implementacji - czytaki. Czytaki służą do automatycznego odczytywania danych (zazwyczaj) binarnych z interfejsu i są niskopoziomową implementacją wcześniej wspomiananego systemu, który określa bajt na którym interfejs komunikacyjny ma zakończyć odczyt danych transakcji LINE. Wbudowane czytaki to odczytujący konkretną ilość bajtów z interfejsu oraz odczytujący do momentu napotkania konkretnego bajtu. Implementacja nowych czytaków jest bardzo prosta: public readonly struct UntilByteRawDataReader : IRawDataReader { public Task<byte[]> PerformRead<TTransactionType>(IRawDataCommunicationInterface communicationInterface, TTransactionType transaction, CancellationToken cancellationToken = default) where TTransactionType : ICommunicationTransaction<TTransactionType> { // Check if transaction is a read until byte transaction if (transaction is ITransactionReadUntilByte untilByte) return communicationInterface.ReadRawDataUntil(untilByte.ExpectedByte, cancellationToken); // If transaction is not supported, throw exception throw new NotSupportedException("Transaction type is not supported."); } } Ten czytak jest jednym z wbudowanych i działa wyłącznie z interfejsami binarnymi (w innym przypadku program wyrzuci wyjątek, że interfejs komunikacyjny nie jest prawidłowego typu). Pozwala on na automatyczny odczyt transakcji zgodnie z predefiniowaną wartością oczekiwanego bajtu. No i teraz wreszcie intefejsy komunikacyjne Interfejsy komunikacyjne to niskopoziomowe obiekty, które służą do komunikacji pomiędzy funkcjami systemowymi (np. port szeregowy), a funkcjami wysokopoziomowymi (np. wykonaj daną transakcję). Są to najbardziej złożone obiekty w IRIS co przekłada się na ich czasochłonną implementację. Poniżej można zobaczyć przykładowy kod interfejsu dla portu szeregowego: public sealed class SerialPortInterface : SerialPort, IRawDataCommunicationInterface { /// <summary> /// Used when reading data stream by single character to prevent unnecessary allocations /// </summary> private readonly byte[] _singleCharReadBuffer = new byte[1]; public SerialPortInterface(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits) { PortName = portName; BaudRate = baudRate; DataBits = dataBits; Parity = parity; StopBits = stopBits; Handshake = Handshake.None; DtrEnable = false; RtsEnable = false; NewLine = Environment.NewLine; ReceivedBytesThreshold = 1024; } /// <summary> /// Connect to device - open port and start reading data /// </summary> /// <exception cref="CommunicationException">If port cannot be opened</exception> public void Connect() { try { // Open the port Open(); if (!IsOpen) throw new CommunicationException("Cannot connect to device - port open failed."); } catch (UnauthorizedAccessException) { throw new CommunicationException( "Cannot access device. Access has been denied. Is any software accessing this port already?"); } } public void Disconnect() => Close(); #region IRawDataCommunicationInterface /// <summary> /// Transmit data to device over serial port /// </summary> void IRawDataCommunicationInterface.TransmitRawData(byte[] data) { if (!IsOpen) throw new CommunicationException("Port is not open!"); // Write data to device Write(data, 0, data.Length); } /// <summary> /// Read data from device over serial port /// </summary> /// <param name="length">Amount of data to read</param> /// <param name="cancellationToken">Used to cancel read operation</param> /// <returns></returns> /// <exception cref="CommunicationException">If port is not open</exception> async Task<byte[]> IRawDataCommunicationInterface.ReadRawData(int length, CancellationToken cancellationToken) { if (!IsOpen) throw new CommunicationException("Port is not open!"); // Create buffer for data // TODO: Get rid of this allocation byte[] data = new byte[length]; int bytesRead = 0; // Read data until all data is read while (bytesRead < length) { bytesRead += await BaseStream.ReadAsync(data, bytesRead, length - bytesRead, cancellationToken); } // Return data return data; } /// <summary> /// Reads data until specified byte is found /// </summary> /// <param name="receivedByte">Byte to find</param> /// <param name="cancellationToken">Used to cancel read operation</param> /// <returns>Array of data, if byte is not found, empty array is returned</returns> /// <exception cref="CommunicationException">If port is not open</exception> async Task<byte[]> IRawDataCommunicationInterface.ReadRawDataUntil(byte receivedByte, CancellationToken cancellationToken) { // Check if device is open if (!IsOpen) throw new CommunicationException("Port is not open!"); // Read data until byte is found // TODO: Get rid of this allocation List<byte> data = new List<byte>(); // Read data until byte is found while (true) { int bytesRead = await BaseStream.ReadAsync(_singleCharReadBuffer, 0, 1, cancellationToken); // Check if data is read if (bytesRead == 0) continue; // If data is read, add it to list data.Add(_singleCharReadBuffer[0]); // Check if byte is found if (_singleCharReadBuffer[0] == receivedByte) break; } // Return data return data.ToArray(); } #endregion Obserwatory Obserwatory to obiekty, których rolą jest wykrywanie zmian w podłączonych urządzeniach. Robią to skanując wszystkie dostępne urządzenia co pewien czas (standardowo 500ms) oraz porównując dane do poprzedniego skanu. Gdy dane się różnią aktualizują pamięć podręczną (usuwają/dodają urządzenia) jednocześnie wysyłając zdarzenia o dodaniu/usunięciu urządzenia. Przykładowo chcąc obserwować wszystkie systemowe porty szeregowe tworzymy obserwator: public sealed class SerialPortDeviceWatcher : DeviceWatcherBase<SerialPortDeviceWatcher, SerialPortDeviceAddress> { /// <summary> /// Scan for all available devices /// </summary> protected override Task<(List<SerialPortDeviceAddress>, List<SerialPortDeviceAddress>)> ScanForDevicesAsync(CancellationToken cancellationToken) { // Get all available serial ports string[] ports = SerialPort.GetPortNames(); // Create lists for hardware and software devices List<SerialPortDeviceAddress> hardwareDevices = new(); List<SerialPortDeviceAddress> softwareDevices = new(); // Loop through all ports for (int serialPortIndex = 0; serialPortIndex < ports.Length; serialPortIndex++) { string port = ports[serialPortIndex]; // Create device address SerialPortDeviceAddress deviceAddress = new(port); // Add device to list hardwareDevices.Add(deviceAddress); softwareDevices.Add(deviceAddress); } // Return devices return Task.FromResult((hardwareDevices, softwareDevices)); } } A następnie możemy go użyć w programie: public static class ExampleCOMRecognitionApp { private static SerialPortDeviceWatcher _deviceWatcher = default!; public static async void RunApp() { // Create new COM recognition watcher _deviceWatcher = new SerialPortDeviceWatcher(); // Attach event handler _deviceWatcher.OnDeviceAdded += OnDeviceAdded; _deviceWatcher.OnDeviceRemoved += OnDeviceRemoved; // Start watching for COM devices _deviceWatcher.Start(); } public static async void KillApp() { // Stop watching for COM devices _deviceWatcher.Stop(); } private static void OnDeviceRemoved(SerialPortDeviceAddress hardwareDevice, SerialPortDeviceAddress softwareDevice) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"Device disconnected: {softwareDevice.Address}"); Console.ResetColor(); } private static void OnDeviceAdded(SerialPortDeviceAddress hardwareDevice, SerialPortDeviceAddress softwareDevice) { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine($"Device connected: {softwareDevice.Address}"); Console.ResetColor(); } } Obserwatory są w pełni asynchroniczne (działają w tle). Powyższy przykład wykrywa zmiany w urządzeniach portu szeregowego oraz wyświetla stosowny monit na konsoli. Podsumowanie To by było chyba na tyle... Projekt można znaleźć na GitHubie oraz przejrzeć Przykłady, znajomość języka Szekspira się przydaje. Plany na przyszłość? Obsługa BLE Obsługa REST poprzez WiFi dla ESP32 (tutaj się zastanawiam) Obsługa MQTT? Zrobienie obserwatora USB dla Linuxa (obecny działa wyłącznie na Windowsie) ... pewnie coś jeszcze 😉 9 Link do komentarza Share on other sites More sharing options...
Popularny post H1M4W4R1 Grudzień 1, 2024 Autor tematu Popularny post Udostępnij Grudzień 1, 2024 (edytowany) Programista poszedł wreszcie po rozum do głowy i zgodnie z zasadą Keep It Simple Stupid zredukował ten boilerplate do minimum... W skrócie wersja 2.1: Usunięto całkowicie system transakcji, który był masochistyczny 😄 Usunięto powiązane subsystemy DataEncoder, DataDecoder i DataReader Przeniesiono system protokołów komunikacyjnych z poziomu interfejsu na poziom urządzenia. Tym samym implementacja samego urządzenia jest teraz znacznie prostsza. Tutaj przykład prostej komunikacji: using IRIS.Addressing; using IRIS.Communication.Serial.Settings; using IRIS.Examples.Devices; namespace IRIS.Examples.Arduino.RUSTIC_LEDApplication { /// <summary> /// Represents an example of a device that changes or reads the value of an LED /// using RUSTIC protocol messages. /// </summary> public sealed class ExampleArduinoLEDChangingDevice( SerialPortDeviceAddress deviceAddress, SerialInterfaceSettings settings) : RUSTICDeviceBase(deviceAddress, settings) { private const string LED_PROPERTY = "LED"; /// <summary> /// Get LED value /// </summary> public async Task<bool> GetLEDValue(CancellationToken cancellationToken = default) { return await GetProperty(LED_PROPERTY) == "1"; } /// <summary> /// Set LED value /// </summary> public async Task<bool> SetLEDValue(bool value, CancellationToken cancellationToken = default) { // Set property to desired value await SetProperty(LED_PROPERTY, value ? "1" : "0"); // Return new value to ensure it was set correctly return await GetLEDValue(cancellationToken); } } } To urządzenie wykorzystuje bazową klasę RUSTICDeviceBase, która służy do obsługi tego konkretnego protokołu. using System.Text; using IRIS.Addressing; using IRIS.Communication.Serial.Settings; using IRIS.Devices; namespace IRIS.Examples.Devices { /// <summary> /// Base class for RUSTIC devices /// </summary> public abstract class RUSTICDeviceBase( SerialPortDeviceAddress deviceAddress, SerialInterfaceSettings settings) : SerialDeviceBase(deviceAddress, settings) { /// <summary> /// Sends SET message to device and returns the response <br/> /// E.g. PROPERTY to desired value /// </summary> /// <remarks> /// Uses ToString() method to convert <see cref="value"/> to string /// </remarks> protected async Task SetProperty<TValueType>(string message, TValueType value) { // Check if value is null, if so throw exception if (value == null) throw new ArgumentNullException(nameof(value)); // Send message with embedded value await RawHardwareAccess.TransmitRawData(Encoding.ASCII.GetBytes(message)); // Send request information await RawHardwareAccess.TransmitRawData(Encoding.ASCII.GetBytes($"={value.ToString()}")); // Send new line await RawHardwareAccess.TransmitRawData(Encoding.ASCII.GetBytes("\r\n")); } /// <summary> /// Sends GET message to device and returns the response <br/> /// </summary> protected async Task<string> GetProperty(string propertyName) { // Send message with embedded value await RawHardwareAccess.TransmitRawData(Encoding.ASCII.GetBytes(propertyName)); // Send request information await RawHardwareAccess.TransmitRawData(Encoding.ASCII.GetBytes("=?")); // Send new line await RawHardwareAccess.TransmitRawData(Encoding.ASCII.GetBytes("\r\n")); // Wait for response byte[] response = await RawHardwareAccess.ReadRawDataUntil(0x0A, CancellationToken.None); // Return decoded response return Encoding.ASCII.GetString(response); } } } Zmieniło się też jedno z założeń protokołu RUSTIC (by ułatwić implementację): Teraz urządzenie nie zwraca OK po ustawieniu wartości. Wystarczy odpytać urządzenie o aktualną wartość, by mieć pewność, że wartość została ustawiona prawidłowo. W ten sposób redukuje się problemy wynikające z potencjalnych błędów w oprogramowaniu urządzenia, które zwróci informację o tym, iż wartość została zmieniona, ale będzie to informacja fałszywa. Podsumowując Wersja 2.1 to znaczące odchudzenie kodu do poziomu tego, że z oryginału zostały tylko urządzenia, interfejsy oraz obserwatory 😉 Dając programiście bezpośredni dostęp do komunikacji z urządzeniem można znacznie łatwiej i przyjemniej implementować protokoły niskiego poziomu. Skąd taka modyfikacja: z autopsji... Transakcje wymagały ogromnej ilości dodatkowego kodu, który nie przekładał się zbytnio na funkcjonalność tego API. TODO: Protokół wymiany danych oparty o komendy-rejestry i UART (potrzebny mi do obecnego projektu) 32-bitowy identyfikator rejestru oraz 32-bitowa wartość kompatybilny z CANbus'ową wersją zastosowanie do debugowania i konfigurowania urządzeń z poziomu aplikacji komputerowej, gdy urządzenie nie ma dostępu do WiFi Obsługa BLE Obsługa REST Obsługa Telnet Obsługa MQTT Dodanie obserwatora USB dla pingwinów ... Edytowano Grudzień 1, 2024 przez H1M4W4R1 7 Link do komentarza Share on other sites More sharing options...
ackarwow 24 stycznia Udostępnij 24 stycznia Fajny projekt. Gratuluję pomysłu 😀 Link do komentarza Share on other sites More sharing options...
H1M4W4R1 14 lutego Autor tematu Udostępnij 14 lutego (edytowany) A tu nagle niespodzianka... Pan beznadziejny programista dorobił moduł BLE do IRIS 😉 Słowem wstępu Moduł BLE jest akceptowalnie stabilny, został zaprojektowany tak, by nie zgłaszać jakichkolwiek wyjątków (poza timeout'ami, które leżą po stronie zewnętrznego programisty - jeżeli je doda to system uwzględnia ich możliwość i przekazuje wyjątek dalej). W pozostałych przypadkach założenie całego API jest takie, by zwracać marginalną ilość wyjątków i obsługiwać każdy możliwy scenariusz - np. jeżeli chcemy podłączyć już podłączone urządzenie to zwracamy informację, że podłączenie było udane (bez wywoływania zdarzeń przy podłączeniu). W ten sposób programista wie, że urządzenie ma gwarancję połączenia, a program jest odporny na wielokrotne wywołania funkcji podłączenia. Oraz dość ważna rzecz: działa tylko na Windowsie Ogólna zasada działania BLE w IRIS jest uproszczone - obsługuje urządzenia wykrywane za pomocą konkretnej usługi (GUID) lub konkretnej nazwy. W przypadku GUID IRIS oczekuje na konkretne ogłoszenie od urządzenia BLE, co jest typowym podejściem do implementacji (np. jeżeli szukamy opaski mierzącej tętno to wybieramy usługę "Heart Rate Service" jako "bazę" naszego urządzenia i w momencie zgłoszenia się pierwszego urządzenia z tą usługą zostanie ono podłączone). Nazwy są dużo bardziej skomplikowane - API monitoruje wszystkie rozgłoszenia i weryfikuje zgodność nazw przy użyciu RegEX'a. W tym przypadku gdy np. chcemy wszystkie urządzenia z prefixem DEVICE- wystarczy użyć RegEX'a (DEVICE-.*) System EndPoint'ów Sam bazowy model urządzenia posiada zaś "Endpoint'y", których nazwa została zainspirowana przez USB, ale w dużym uproszczeniu są to powiązania do konkretnych charakterystyk w konkretnych usługach urządzenia. Endpoint'y mogą być wymagane lub opcjonalne (np. opaska na ramię wymaga pomiaru pulsu, ale może mieć też opcjonalny endpoint do pomiaru SPO2 jeżeli jest dostępne). Przykładowa rejestracja Endpoint'a w urządzeniu: protected override async ValueTask AttachOrLoadEndpoints() { // Attach the heart rate endpoint // we don't need to notify interface for disconnection as // it will be automatically handled in endpoint methods HeartRateEndpoint = await AttachEndpoint(HEART_RATE_ENDPOINT_ID, GattServiceUuids.HeartRate, HEART_RATE_CHARACTERISTIC_INDEX, HandleHeartRateNotification); } Endpoint posiada własny identyfikator liczbowy (uint, powinno wystarczyć do każdego zastosowania), konkretny GUID usługi, indeks lub GUID charakterystyki. W przypadku użycia metody LoadEndpoint(id, service, characteristic, mode = REQUIRED) nie występuje dodatkowe pole na metodę do wywołania w momencie wystąpienia notyfikacji, co ma miejsce w przypadku użycia AttachEndpoint(id, service, characteristic, callback, mode = REQUIRED) - używając AttachEndpoint automatycznie możemy zasubskrybować notyfikacje na urządzeniu. W momencie, gdy urządzenie będzie poza zasięgiem w momencie szukania punktów krańcowych API automatycznie odłączy urządzenie jako niedostępne. W przypadku błędu protokołu spróbuje pracować dalej. Jeżeli będzie brakowało wymaganego punktu krańcowego urządzenie się rozłączy po załadowaniu wszystkich punktów. Odczytywanie danych W momencie otrzymania notyfikacji (lub chęci odczytania danych z Endpoint'a) możemy użyć metody ReadData<T> na konkretnym punkcie krańcowym: /// <summary> /// Process the raw data received from the device into application usable data, /// </summary> private async void HandleHeartRateNotification(GattCharacteristic sender, GattValueChangedEventArgs args) { // If endpoint failed, return if (HeartRateEndpoint == null) return; // Read the data from the endpoint and return if it is null byte[]? data = await HeartRateEndpoint.ReadData<byte[]>(); if (data == null) return; // Process the data HeartRateReadout heartRate = ProcessData(data); // Notify listeners OnHeartRateReceived(heartRate); } T jest ograniczone do kilkunastu podstawowych typów (liczbowe, bool, tekst, tablica bajtów, data, przedział czasowy i GUID). W przypadku nieudanego odczytu zostanie zwrócony wskaźnik null (urządzenie może się rozłączyć jeżeli przyczyną nieudanego odczytu był brak połączenia). Pełen kod przykładowego urządzenia można znaleźć na GitHubie. Podsumowanie Czy da się łatwo zaimplementować urządzenie BLE? Tak. Czy da się łatwo połączyć... zależy od miejsca, gdzie jest antena / dongle (polecam podłączenie na przedłużce USB, daje lepszy zasięg). Czekają mnie jeszcze testy z pełni customowym urządzeniem (własne usługi i protokół), ale jestem dobrej myśli. Kandydatem testowym do BLE była opaska Magene 😉 Edytowano 15 lutego przez H1M4W4R1 2 Link do komentarza Share on other sites More sharing options...
Polecacz 101 Zarejestruj się lub zaloguj, aby ukryć tę reklamę. Zarejestruj się lub zaloguj, aby ukryć tę reklamę. Produkcja i montaż PCB - wybierz sprawdzone PCBWay! • Darmowe płytki dla studentów i projektów non-profit • Tylko 5$ za 10 prototypów PCB w 24 godziny • Usługa projektowania PCB na zlecenie • Montaż PCB od 30$ + bezpłatna dostawa i szablony • Darmowe narzędzie do podglądu plików Gerber Zobacz również » Film z fabryki PCBWay
Popularny post H1M4W4R1 17 lutego Autor tematu Popularny post Udostępnij 17 lutego Redukcja metod asynchronicznych do zera Kod synchroniczny znacznie łatwiej przewidzieć, zwłaszcza w przypadku kontrolowania urządzeń, które mogą spowodować problemy gdy dostaną złą komendę. Trochę to zajęło, ale udało się posprzątać 😉 // Tworzenie instancji urządzenia (w tym przypadku opaska do pomiaru tętna na przedramię) BluetoothLowEnergyHeartBand heartBand = new(); // Łączenie się z urządzeniem i sprawdzanie statusu, w pakiecie timeout 5s if(heartBand.Connect(new ResponseTimeout(5_000))) Console.WriteLine("Connected to device"); else Console.WriteLine("Failed to connect to device"); // Sprawdzanie statusu podłączonego urządzenia Console.WriteLine("Device is connected: " + heartBand.IsConnected); // Oraz rozłączanie się z urządzeniem if(heartBand.Disconnect()) Console.WriteLine("Disconnected from device"); else Console.WriteLine("Failed to disconnect from device"); Jak już wyżej wspomniałem - redukując kod asynchroniczny udało się zapewnić w pełni synchroniczny cykl pracy urządzenia, który i tak prawdopodobnie w większości przypadków byłby oczekiwany, ale powodowałby ogromne ilości zbędnych alokacji na stercie. Nowe typy danych zwracanych Nastąpiło również przerobienie zwrotów danych na interfejsach z nullable na nową klasę - DeviceResponseBase, która pozwala w łatwy sposób sprawdzić odpowiedź z urządzenia. Tym samym dodałem też strukture DataPromise do stosowania w protokołach oraz miejscach, gdzie użytkownik może spodziewać się zwrotu danych z opcją oznaczenia jej jako sukces lub porażkę, co pozwala na łatwą identyfikację czy dane otrzymane od urządzenia są poprawne i odpowiednią reakcję na taką sytuację. DeviceResponseBase IRawDataCommunicationInterface.ReadRawData(int length, CancellationToken cancellationToken) { if (!IsOpen) { // Odłączone urządzenie nie jest w stanie odpowiedzieć return NoResponse.Instance; } // ... while (bytesRead < length) { try { // Odczytaj dane z urządzenia (na osobnym wątku) Task<int> readTask = BaseStream.ReadAsync(data, bytesRead, length - bytesRead, cancellationToken); // Oczekaj aż dane zostaną odczytane while (!readTask.IsCompleted) { // W przypadku anulowania operacji zwróć informację o przekroczeniu czasu oczekiwania if (cancellationToken.IsCancellationRequested) return RequestTimeout.Instance; } // ... } catch (TaskCanceledException) { // Jeżeli odczytywanie danych zostało anulowane zwróć informację o przekroczeniu czasu oczekiwania return RequestTimeout.Instance; } } // Gdy wszystko przebiegło pomyślnie zwróć dane ;) return new RawDataResponse(data); } Również instancje uniwersalne (tj. przekroczenie czasu oczekiwania czy brak odpowiedzi), które nie zawierają dodatkowych parametrów mają jedną stałą instancję, by ograniczyć zbędne alokacje sterty. Interfejsy portu szeregowego są już kompatybilne z tym trybem. BLE zostało zaprojektowane w trochę inny sposób i ze względu na obecność protokołu pośredniczącego w komunikacji (nie jest to surowa transmisja danych) większość metod zwraca DataPromise, przykład poniżej. public DataPromise<GattCharacteristic> GetCharacteristic(Guid serviceUUID, Guid characteristicUUID) { // Sprawdź czy urządzenie jest podłączone if (!IsConnected) return DataPromise<GattCharacteristic>.FromFailure(); // Znajdź usługę używając wewnętrznej metody DataPromise<GattDeviceService> service = GetService(serviceUUID); // Sprawdź czy usługa jest sukcesem i została znaleziona, jeżeli tak to spróbuj pobrać // charakterystykę o określonym UUID z tej usługi return !service.HasData ? DataPromise<GattCharacteristic>.FromFailure() : GetCharacteristic(service.Data, characteristicUUID); } Dlaczego staram się minimalizować alokacje pamięci... cóż testując jedno urządzenie wyszedł pewien problem z alokacjami... taki niewielki... 2GB alokacji na stosie 😄 Akurat to był mały błąd w przekazywaniu struktury poprzez wartość zamiast referencji... w funkcji wykonującej się 250 razy na sekundę, no ale nadal... lepiej optymalizować, póki projekt jest dość mały. Także, ja wracam szukać miejsc do optymalizacji kodu i liczę, że ktoś może podrzuci jakieś pomysły na systemy komunikacji / interfejsy do dorzucenia do tego systemu (testowałem też komunikację z losową aplikacją na komputerze, więc chyba trzeba będzie przerobić nazwę zanim zacznę używać tego systemu jeszcze bardziej poza jego oryginalną koncepcją). 3 Link do komentarza Share on other sites More sharing options...
Pomocna odpowiedź
Bądź aktywny - zaloguj się lub utwórz konto!
Tylko zarejestrowani użytkownicy mogą komentować zawartość tej strony
Utwórz konto w ~20 sekund!
Zarejestruj nowe konto, to proste!
Zarejestruj się »Zaloguj się
Posiadasz własne konto? Użyj go!
Zaloguj się »