https://visualhunt.com/photo/130101/

Czujnik temperatury DS1820 pod USB

Jest długi weekend i … pada. Wszystkie obowiązki na dziś już zrealizowałem, więc to najlepszy czas, aby zacząć produkować coś na bloga. Pomysł na kolejne wpisy pojawił się już jakiś czas temu, postanowiłem opisać po swojemu kilka wzorców projektowych. Na pierwszy ogień miał pójść wzorzec Obserwatora i tak też się stanie, lecz dopiero w kolejnym poście. 🙂 W trakcie wymyślania przykładu, na jakim chciałem pokazać jego zasadę, odgrzebałem w czeluściach mojego dysku twardego skrypt w BASHu realizujący odczyt temperatury z czujnika DS1820 podpiętego do portu USB, dodający zmierzoną wartość do tabeli w bazie PostgreSQL i generujący wykres temperatury w bieżącym dniu. Skrypt był odpalany okresowo za pomocą programu CRON.

Wydaje mi się, że przykład dobrze nadaje się do pokazania wzorca Obserwatora, tym jednak zajmę się w najbliższych dniach. Dziś opiszę sposób, w jaki odczytuję temperaturę z czujnika wykorzystując interfejs konsolowy digitemp dla systemu Linux i jak przekazuję te dane do programu napisanego w Javie.

Termometr USB (DS1820)

Układ scalony do pomiaru temperatury typu DS1820 już pojawił się na moim blogu podczas projektu konkursowego DSP2017, w dwóch postach dotyczących sprzętowej części sterownika (cz. 1 i cz. 2). Poprzednio czujnik był podpięty bezpośrednio do jednopłytkowego minikomputera Raspberry Pi i oprogramowany w języku Python bezpośrednio na nim. Obecnie moim celem jest podłączenie czujnika wykorzystującego interfejs 1-wire do laptopa, przez port szeregowy. W związku z tym, że obecnie porty szeregowe nie pojawiają się powszechnie i w moim Lenovo niestety go brakuje, zostałem zmuszony do podpięcia czujnika przez konwerter RS-232 <-> USB.

Taka konfiguracja wymagała użycia lutownicy do połączenia kilku prostych, pasywnych elementów elektronicznych do gniazda typu D-Sub 9. W związku z tym, że mój blog ma być bardziej programistyczny niż elektroniczny, nie będę zanudzał tworząc rozdział typu DIY (Zrób to sam), tym bardziej, że byłoby to powielenie informacji, które łatwo można znaleźć w internetach. Po więcej informacji na temat tego typu podłączenia odsyłam na stronę linuxiarz.pl.

Mało efektowny czujnik DS1820 w przejściówką 1-wire <-> USB
Mało efektowny czujnik DS1820 w przejściówką 1-wire <-> USB

To kolejny „pająk” w moim wydaniu i może nie wygląda to zbyt efektownie, ale co najważniejsze działa! 🙂

Odczyt danych z czujnika (Python)

Postanowiłem trochę ubarwić rozwiązanie i wywołanie programu digitemp wydzieliłem od reszty aplikacji, przygotowując skrypt w języku Python. Oczywiście można to samo zrobić w Javie, co by było zapewne zgrabniejszym rozwiązaniem, jednak mój wybór dodaje dla mnie niewielki walor edukacyjny.

#!/usr/bin/python3
# -*- coding: utf-8 -*-
 
from subprocess import Popen, PIPE
 
 
def get_temperature():
	command = ["digitemp_DS9097", "-a", "-q", "-s" "/dev/ttyUSB0", "-c", "/home/maciek/Skrypty/digitemp.conf",
			   "-o", "\"%R;%.2C\""]
	cmd = Popen(command, stdout=PIPE, stderr=PIPE)
	cmd_out, cmd_err = cmd.communicate()
	if cmd_err == b'':
		return cmd_out
	else:
		return b'error'
 
 
if __name__ == "__main__":
	return_value = get_temperature(). \
		decode(encoding='UTF-8', errors='strict'). \
		replace("\"", "")
	output = return_value.rstrip("\n")
	print(output)

Uruchomienie skryptu powoduje wyświetlenie w terminalu wyniku w postaci id_sensora;wartość_temp_w_st_C, przykładowo: 1086F714010800BF;24.56. W tym układzie może być podłączonych więcej niż jeden czujnik, nic nie stoi na przeszkodzie, aby dane z kolejnych sensorów pojawiały się po znaku nowej linii. Aplikację w Javie przygotowałem w taki sposób, aby była możliwość obsługi właśnie takiego formatu danych.

Właściwy odczyt temperatury realizowany jest w funkcji get_temperature(), która otwiera nowy proces wywołujący komendę digitemp z odpowiednimi parametrami.

  1. -a – odczyt wartości temperatury z wszystkich podłączonych sensorów
  2. -q – wyjście programu bez nagłówka z prawami autorskimi i licencją
  3. -s – ustawienie portu szeregowego
  4. -c – ustawienie pliku z konfiguracją

Plik z konfiguracją jest istotnym elementem tego wywołania. O ile uruchomienie powyższego skryptu bez opcji -c zakończy się pozytywnie, to wywołanie tego samego skryptu z poziomu Javy się nie powiedzie. Dzieje się tak, ponieważ w katalogu z poziomu którego wykonywane jest polecenie digitemp, musi znajdować się plik domyślny .digitemprc lub musi być zdefiniowany plik użytkownika, podpięty opcją -c. Powiem szczerze, że dojście do tego, dlaczego dostaję pusty komunikat z uruchomionego w Javie procesu, zajęło mi ponad godzinę, a na rozwiązanie naprowadził mnie dopiero stackoverflow.com. Nauczka na przyszłość – tak to jest, jak nie czyta się dokumentacji, przez rozpoczęciem kodowania..

digitemp.conf:

TTY /dev/ttyUSB0
READ_TIME 1000
LOG_TYPE 1
LOG_FORMAT "%b %d %H:%M:%S Sensor %s C: %.2C F: %.2F"
CNT_FORMAT "%b %d %H:%M:%S Sensor %s #%n %C"
HUM_FORMAT "%b %d %H:%M:%S Sensor %s C: %.2C F: %.2F H: %h%%"
SENSORS 1
ROM 0 0x10 0x86 0xF7 0x14 0x01 0x08 0x00 0xBF

Plik konfiguracyjny zawiera informacje na temat formatowania ciągu wyjściowego, jak również o częstotliwości wykonywania pomiarów. Domyślnie wartość ta wynosi 1000 ms, przy czym czujnik DS1820 może wysyłać wartość minimum co 750 ms, wobec czego ustawienie mniejszej wartości spowoduje odczyt nieprawidłowych danych. Ten sam parametr można skonfigurować również za pomocą parametru -r polecenia digitemp.

Klasa POJO, czyli model danych punktu pomiarowego

Przechodząc do konkretów, na początku utworzyłem model danych, który przechowywał będzie dane dotyczące pojedynczego pomiaru. Adnotacje przed nazwą klasy pochodzą z paczki Lombok i generują boilerplate code.

Klasa zawiera pola do zapisu daty i czasu pomiaru (LocalDateTime z Java 8), numeru fabrycznego sensora, odczytanej wartości temperatury oraz jednostki. TemperatureUnitEnum to zwykły enum, zawierający trzy opcje: C, F oraz K. Metodę toString() nadpisałem, aby uzyskać przyjemniejszy dla oka wynik działania programu.

@Data
@NoArgsConstructor
class Measurement {
 
    private static final char DEGREE = (char) 176;
 
    private LocalDateTime dateTime;
    private String sensorId;
    private float value;
    private TemperatureUnitEnum unit;
 
    public Measurement(LocalDateTime dateTime) {
        this.dateTime = dateTime;
    }
 
    public Measurement(Measurement measurement) {
        this.dateTime = measurement.getDateTime();
        this.sensorId = measurement.getSensorId();
        this.value = measurement.getValue();
        this.unit = measurement.getUnit();
    }
 
    @Override
    public String toString() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        String formattedDateTime = this.dateTime.format(formatter);
 
        StringBuilder sb = new StringBuilder(formattedDateTime);
        sb.append(" (").append(this.sensorId).append(")").append(": ").append(this.value).append(" ");
        appendUnit(sb);
        return sb.toString();
    }
 
    private void appendUnit(StringBuilder sb) {
        switch (this.unit) {
            case K: {
                sb.append("K");
                break;
            }
            default: {
                sb.append(DEGREE).append(this.unit);
            }
        }
    }
}

Klasa Measurement posiada trzy konstruktory: bezargumentowy (@NoArgsConstructor), przyjmujący wartość typu LocalDateTime oraz konstruktor kopiujący (głębokie kopiowanie wartości, a nie referencji).

Odczyt temperatury z czujnika

Wykonanie odczytu wartości pomiarowej z czujnika realizowane jest w metodzie read() obiektu klasy DS1820Reader w następujących krokach:

  • Utworzenie procesu uruchamiającego skrypt odczytujący
  • Uruchomienie skryptu
  • Pobranie wyników zwróconych przez skrypt
  • Przetworzenie wyników i persystencja w klasie Measurement
@Data
public class DS1820Reader{
 
    private List<Measurement> measurements;
 
    public DS1820Reader() {
        this.measurements = new ArrayList<>();
    }
 
    @Override
    public List<Measurement> read() {
        Process process = doMeasurementProcess();
        this.measurements = readMeasurements(process);
        process.destroy();
        return this.measurements;
    }
    // (...)
}

Uruchomienie procesu wywołującego skrypt

Do utworzenia procesu w systemie operacyjnym można wykorzystać m. in. obiekt klasy ProcessBuilder, która pojawiła się od wersji 1.5 Javy. Utworzony proces (Process) jest zwracany przez metodę start() obiektu budowniczego. Udostępnia on metody umożliwiające m. in. zniszczenie (destroy()), pobranie wartości wyjściowej procesu (exitValue()), sprawdzenie czy proces jest aktywny (isAlive()) oraz pobranie strumieni InputStream (getInputStream() i getErrorStream()) oraz OutputStream (getOutputStream()). Operacje na strumieniach są kluczowe, bo umożliwiają odczyt informacji, które pojawiłyby się na standardowym wyjściu (konsola) w przypadku uruchomienia skryptu w sposób autonomiczny.

Parametry konstruktora określają, jaki zewnętrzny program ma być uruchomiony oraz jakie parametry należy do niego przekazać przy wywołaniu. W tym wypadku odpalam skrypt ds1820read.py za pomocą python3.

    private Process doMeasurementProcess() {
        Process process = null;
        try {
            ProcessBuilder pb = new ProcessBuilder("python3", "/home/maciek/Skrypty/ds1820read.py");
            process = pb.start();
        } catch (IOException ex) {
            Logger.getLogger(DS1820Reader.class.getName()).log(Level.SEVERE, null, ex);
        }
        return process;
    }

Na koniec drobna uwaga. Przy korzystaniu z klasy ProcessBuilder należy mieć świadomość, że nie jest ona synchronizowana, tzn. przy aplikacji wielowątkowej mogą wystąpić problemy podczas operacji modyfikujących ten sam zestaw danych.

Przechwycenie pomiarów ze skryptu

Skrypt już się wykonał i w zależności od wyniku zwrócił numery seryjne czujników i wartości zmierzone lub ciąg znaków „error„. Metoda readMeasurements() realizuje pobranie tych wartości ze strumienia wejściowego, otrzymanego z obiektu klasy Process.

    private List<Measurement> readMeasurements(Process process) throws NumberFormatException {
        List<Measurement> measurements = new ArrayList<>();
        try (BufferedReader in
                = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
 
            String inputLine = null;
            while ((inputLine = in.readLine()) != null) {
                if (errorOccured(inputLine)) {
                    System.err.println("Błąd odczytu temperatury z czujnika DS1820.");
                } else {
                    measurements.add(prepareMeasurementObject(inputLine));
                }
            }
        } catch (IOException ex) {
            Logger.getLogger(DS1820Reader.class.getName()).log(Level.SEVERE, null, ex);
        }
        return measurements;
    }
 
    private static boolean errorOccured(String inputLine) {
        return inputLine.contains("error");
    }

Jak widać, nie dzieje się tu żadna większa magia. Strumień jest odczytywany linia po linii, aż do końca zawartości. Na tej podstawie uzupełniane są obiekty POJO i agregowane w liście. Na pewno warto stosować konstrukcję try() (od wersji 1.7 Javy ), co zwalnia programistę z konieczności zamykania zasobu (AutoCloseable).

Przygotowanie obiektu zawierającego dane pomiarowe

Implementacja tworzenia obiektu klasy Measurement polega na parsowaniu kolejnej linii ze strumienia wejściowego generowanego przez proces. W tym rozwiązaniu wystarczyła prosta metoda split() rozdzielająca numer sensora od wartości pomiarowej.

    private Measurement prepareMeasurementObject(String inputLine) throws NumberFormatException {
        Measurement measurement = new Measurement(LocalDateTime.now());
        measurement.setUnit(TemperatureUnitEnum.C);
        String[] splitedLine = inputLine.split(";");
        try {
            measurement.setSensorId(splitedLine[0]);
            measurement.setValue(Float.parseFloat(splitedLine[1]));
        } catch (ArrayIndexOutOfBoundsException ex) {
            System.err.println("Nieparawidłowy format danych wejściowych.");
            System.exit(0);
        }
        return measurement;
    }

Uruchomienie aplikacji

Dla pełnego obrazu załączam fragment kodu uruchamiający pojedynczy odczyt temperatur i wyświetlający wyniki w terminalu.

  public static void main(String args[]) {
        TemperatureProvider reader = new DS1820Reader();
        List<Measurement> measurements = reader.read();
        System.out.println("\nOdczytana wartość:");
        measurements.forEach(System.out::println);
    }

Output:

Odczytana wartość:
2017-06-15 23:46:49 (1086F714010800BF): 26.0 °C

W dzisiejszym wpisie to by było na tyle. Mam gotowy kod, który posłuży mi do dalszych modyfikacji, w celu zaimplementowania wzorca Obserwatora. Za kilka dni możesz się Drogi Czytelniku spodziewać właśnie takiej tematyki – serdecznie zapraszam!

P.S. Na moim koncie GitHub znajduje się aktualniejsza (bardziej rozbudowana, poddana refaktoryzacji) wersja kodu opisanego w tym wpisie. W momencie w którym to czytasz, prawdopodobnie kod na GitHub zawiera już modyfikacje implementujące wzorzec Obserwatora.

[Edit] Jeszcze nie skończyłem pisać, a już pogoda się zmieniła, więc ten początek może być nieaktualny. Prace musiały nabrać tempa, abym mógł skorzystać z promieni słonecznych. 🙂

Literatura obowiązkowa

Termometr na DS1820 – Magistrala 1-Wire
1-wire temperature sensor cmdline interface (README)
Java SE 8 Date and Time

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *