DS18B20-Modbus-Adapter

Aus Hobbyelektronik.org
Aufgebauter Leiterkarte (v0.1)

Der Plan, der Heizung etwas genauer auf die Finger zu schauen, sie zu verstehen, Fehler möglichst früh zu erkennen und das System schlussendlich zu optimieren, nimmt (in kleinen Schritten) Form an.

So werden die Messwerte der Solaranlage (inkl. ein paar Temperaturwerte der Heizung) und die Stromaufnahme der Wärmepumpe mittlerweile in eine InfluxDB geloggt, aber das soll nicht alles bleiben.

Um besseren Einblick zu bekommen was rein und raus geht, sollen u. a. die Temperaturen für Vor- und Rückläufe, Mischerstellungen und Drücke erfasst werden. Ideal wäre natürlich auch, die Durchflussmenge zu messen (hier fehlt es aber noch an Zählern - dazu muss der Heizungsbauer ran...).

Wenn man sich mit Automatisierung und deren Kommunikation beschäftigt, kommt man fast nicht um Modbus. Das Protokoll ist relativ einfach, robust und hat sich in der Industrie etabliert.

Da dieser in Form von Stromzählern bereits Einzug ins Haus gefunden gefunden hat und dadurch eine Verkabelung vorhanden ist, ist es ein logischer Schritt, auf diesen Bus aufzubauen.

Zwar kann man fertige Messwandler für Modbus kaufen, die können dann oft nur "Eines" und sind, gerade wenn sie aus dem Industrie-Umfeld sind, entsprechend teuer. Warum also nicht selber bauen?

Plan & Features

Der ATtiny1616 hat ein gutes Preis-/Leistungsverhältnis, verfügt über eine angenehme Anzahl an I/Os, ist einfach zu programmieren (man brauch nicht mehr als einen USB-UART-Adapter) und die Verfügbarkeit ist gut.

Als Temperaturfühler sollen, weil ebenfalls preiswert und einfach in der Verkabelung, Sensoren mit 1-Wire zum Einsatz kommen. Genauer gesagt DS18B20 bzw. deren vielfältige Fakes/Clones/Nachahmer.

Um die Stellung von Mischern oder analogen Manometern zu erfassen, soll ein magnetischer Encoder (genauer ein AMS AS5600) eingesetzt werden. Mit einem auf die Achse geklebten diametralen Magnet lässt sich so ohne nennenswerte Eingriffe die Position mit hoher Auflösung erfassen.

Mehr ist natürlich immer besser:

Da der Mikrocontroller über mehrere ADC-Kanäle verfügt, sollen diese ebenfalls zur Verfügung stehen können.

Um Zählausgänge (zum Beispiel von Wasseruhren) zu erfassen, soll ebenfalls ein Pulszähler zu den Funktionen gehören.

Hardware

Schaltplan

Der Schaltplan ist relativ straight forward:

Da die externe Stromversorgung irgendwas sein kann, regelt ein 78(L)05. Für ein bisschen Flexibilität sind gleich 3 Footprints auf der Leiterkarte: im SOT89-Gehäuse (weil schön klein und gute thermische Anbindung), SO8-Gehäuse (weil davon sicher einer herumliegt) und im TO220-Gehäuse. Letzteren nicht, weil man ihn bräuchte, aber man kann bei Bedarf auch so etwas wie einen Mini-Schaltwandler einbauen.

Für Modbus hängt ein MAX485 am UART des Mikrocontrollers, für DIR wird die XDIR-Funktionalität des Mikrocontroller ignoriert, weil die dafür verfügbaren Pins etwas blöd liegen (mehr dazu später).

I²C ist ebenfalls einfach: zwei Pull-ups und fertig.

Etwas aufgehoben, aber ehrlicherweise als erstes festgelegt: 1-Wire. Der Einfachheit halber soll jeder Sensor seinen eigenen Pin bekommen (auch wenn man mehrere Devices über einen Bus ansprechen kann). Da das Auslesen die CPU etwas länger blockieren kann und um die Werte synchron zu erhalten sollen alle Sensoren gleichzeitig angesprochen werden, dazu sollen möglichst viele Pins von einem Port genutzt werden - der ATtiny1616 hat 3 Ports mit unterschiedlich vielen IOs, wobei es eine relativ starke (wenn auch etwas flexible) Doppelbelegung besteht. PORTA ist mit 8 IOs am "breitesten", wobei PA0 als UPDI-Pin verwendet wird. Ohne HV UPDI, einem guten Bootloader oder dem Vertrauen, dass die Firmware fertig ist.

Wie auch immer: 7 Kanäle sind noch immer besser als 6 (an PORTB).

Um bei der Verwendung der Sensorkanäle flexibel zu sein, beinhaltet das Design Bestückungsoptionen für Serienwiderstände (per default überbrückt), Pull-Ups und Kondensatoren um Tiefpassfilter einzubauen. Zudem gibt es an allen nach außen geführten Leitungen ESD-Schutz. Darf auch mal sein.

Als Anschlussklemmen gibt es zur Abwechslung mal keine Schraubklemmen, sondern Federkraftklemmen. Um nicht für jeden der Sensoren jeweils zwei Klemmen für die Versorgung zu spendieren (bei 7 Temperaturfühlern wären das immerhin 21 KIemmen), teilen sich zwei Sensoren ein Pärchen für die Versorgung, was die Klemmenanzahl auf 16. Mit zwei 12er-Klemmblöcken lässt sich so die Versorgung, RS485-Interface, 8 Sensoreingänge und ein I²C-Interface unterbringen.

Zur Statusanzeige dienen zwei LEDs, die sich die IOs mit dem auf eine Stiftleiste herausgeführten SPI-Interface teilen.

Die Stiftleiste stellt zugleich das Programmierinterface und den GPIO (der auch als Analogeingang genutzt werden kann) zur Verfügung.

Der Plan, die Modbus-Adresse über ein Mäuseklavier zu konfigurieren scheiterte in der ersten Hardware-Revision. Ein analoges Signal auf einen der wenigen Pins zu führen, der nicht über einen ADC-Kanal verfügt ist immerhin auch eine Leistung ;)

Viel Text, jetzt Butter bei die Fische - der Schaltplan:

Layout

Wie immer folgt das Layout dem Prinzip "So groß wie nötig, so klein wie möglich". Die Breite der Leiterkarte wird von den Federkraftklemmen vorgegeben, die Länge ergibt sich durch das Design.

Um keinen einseitigen Wust an Leitungen zu haben, sind die Klemmen gegenüberliegend platziert, in der Mitte sitzt die ganze Logik.

ESD-Schutz und (optionale Filter RC-Filter) liegen so nah wie möglich an den Klemmen.

Da für die - wie sich im Nachhinein herausstellte - etwas zu knapp platzierten Schraublöchern ungenutzter Platz entstand, gibt es Beschriftungen für die Klemmblöcke.

BOM

Menge Referenz Wert Package Reichelt Bestellcode
1 SV2 MA04-1R MPE 087-1-004
1 SV1 MA08-1R MPE 087-1-008
4 C2, C3, C5, C14 0u1 C0603 KEM Y5V0603 100N
1 C1 0u1 C0805 KEM X7R0805 100N
1 C4 10u C0805 KEM X5R0805 10U
1 R24 120 R0603 RND 155HP03 AL
1 R14 1k5 R0603 SMD-0603 1,5K
1 R13 2k2 R0603 SMD-0603 2,2K
2 R10, R11 47k R0603 SMD-0603 47K
1 C9 47u/25V E2,5-5 HD-A 47U 25
9 R1, R2, R3, R4, R5, R6, R7, R8, R18 4k7 R0603 SMD-0603 4,7K
1 R9 4k7 R0603 SMD-0603 47K
1 IC5 78L05F SOT89 TS 78L05 ACY
1 IC1 ATTINY1616 SO20_T1616 ATTINY1616-SN
1 D9 BAT43WS SOD323-W BAT 43WS
2 X1, X2 DG250-3.5-12P-1Y-00A DG250-3.5-12P-1Y-00A DG250 3,5-12
1 IC3 MAX481CSA SO08 MAX 481 CSA
1 D7 P4SMAJ18CA DO214AC P4SMAJ18CA
6 D1, D2, D3, D4, D5, D6 PESD1CAN SOT23 PESD 1CAN
1 LED1 gn SML0805 SMD-LED 0805 GN
1 LED2 rt SML0805 SMD-LED 0805 RT

Aufbau

Beim Bestücken der Leiterkarte sollten die Federkraftklemmen zumindest auf der Oberseite wirklich als letzte Bauteile bestückt werden, sonst wird es es ein sehr unangenehmes Unterfangen.

Die Fuses für den Mikrocontroller sind in der Firmware "eingebacken". Es kommt ein wenig auf den Programmer an, ob die Sektion im ELF-File unterstützt wird. Für alle anderen Fälle, hier die Fuses fürs manuelle Schreiben und Prüfen:

Register Wert
WDTCFG 0x02
BODCFG 0x06
OSCCFG 0x01
TCD0CFG 0x00
SYSCFG0 0xC5
SYSCFG1 0x30
APPEND 0x00
BOOTEND 0x00

Software

1-Wire

Lange habe ich mich gescheut, etwas mit 1-Wire zu machen. Hauptsächlich weil die meisten Bibliotheken das Timing mit Sleeps erzeugen und somit höchst inkompatibel mit Interrupt-getriebenen Anwendungen sind. Um es selbst mit Interrupts zu programmieren war die Not nicht groß genug.

Offenbar hatte Peter Dannegger ähnliches im Sinn und es auf clevere Weise gelöst.

Der Code spricht allerdings nur mit einem Sensor an einem IO. Man könnte nun den Code zur Verwendung unterschiedlicher Pins umbauen, oder mehrere Sensoren pro Pin verwenden kann. Ersteres hätte jedoch die Nebenwirkung, dass das Auslesen in die Länge gezogen wird und das die Werte nicht synchron sind (was in den meisten Fällen jedoch zu verschmerzen wäre). Letzteres ist deutlich aufwändiger, da ohne die Kenntnis der Seriennummer der Busteilnehmer auch eine Suchfunktion implementiert werden muss, auch ist die eindeutige Zuordnung Sensor zu Pin nicht ohne Weiteres möglich (und zu guter Letzt wird auch die Registerzuordnung in Richtung Modbus etwas komplexer).

Ob man nun auf einen Pin oder einen ganzen Port zugreift macht bei den meisten Mikrocontrollern keinen großen Unterschied, weshalb ich den Code dementsprechend umgebaut hab. Im Zuge dessen ist nun auch der IO-Zugriff etwas stärker von der Statemachine getrennt, was die Portierung auf andere Plattformen erleichtern dürfte. Auch die Methoden für die Kommunikation mit den DS18B20 wurde etwas stärker vom 1-Wire-Interface getrennt und eine kleine Statemachine gebaut, um den Lesezyklus zu vereinfachen - so muss man die Messung nur noch starten, periodisch eine Tick-Funktion aufrufen, die einem auch gleich sagt, ob Ergebnisse vorliegen. Die Temperatur der einzelnen Kanäle kann als signed fixed point gelesen werden.

Da manche der DS18B20-Clones wohl die Eigenschaft haben, sich nach einer gewissen Anzahl an Lesezyklen selbst zu zerstören (kann die Quelle leider nicht mehr finden), geschieht das Auslesen nur auf Anfrage. Mehr dazu weiter unten.

I²C

Zum Auslesen des magnetischen Encoders wird I²C verwendet.

Das Programm initialisiert den Chip sobald er erkannt wird und versetzt ihn in den Stromspar-Modus LPM1. Der Slow Filter wird (unnötigerweise) auf 16x konfiguriert, Der Fast Filter bleibt wie der Watchdog aus.

Die Einstellungen lassen sich in config.h ändern.

Neben dem Winkel lassen sich über Modbus auch die Statusregister auslesen. Mehr dazu unten.

Analogeingang

Ein Pin am AVR ist noch frei, eine Klemme am Klemmblock konnte freigeräumt werden und ich möchte eine Status-Lampe überwachen. Durch den ADC lässt sich eine größere Außenbeschaltung vermeiden und die Flexibilität per Software ist groß.

Auch hier lässt sich die Konfiguration für die Wandlung in config.h ändern. Standardmäßig wird die 5V-Versorgungsspannung als Referenz verwendet und über 8 Samples akkumuliert.

Pulszähler

Für den Pulszähler braucht es eigentlich nur eine einigermaßen gute Entprellung bzw. vielmehr eine Qualifizierung der Gültigkeit der Länge eines Pulses.

Dazu wird die Anzahl der Ticks nach einem Pegelwechsel gezählt. Der Teiler für die Ticks und die Anzahl der Ticks - also die Qualifikationszeit kann (ein gemeinsamer Wert für alle Eingänge) in config.h definiert werden.

Neben den Sensoreingängen werden auch Pulse auf AIN gezählt.

Aktuell werden nur die negativen Pulse per Modbus zur Verfügung gestellt.

Modbus

Da das Rad nicht neu erfunden werden muss, kommt für das Modbus-Interface yaMBSiavr zum Einsatz. Die Bibliothek wurde für den avrtiny-1-Core angepasst (und natürlich ein Pull-Request erstellt). Zusätzlich gab es eine kleine Erweiterung: nun ist die Möglichkeit eingehackt, den Server zu identifizieren (nach Abschnitt 6.21 der V1.1b3 Spec).

Dreh- und Angelpunkt für die Kommunikation sind die Input- und Holding-Register, die im folgenden Abschnitt beschrieben ist:

Benutzung

Konfiguration

Die Konfiguration findet vorwiegend in config.h statt.

Pin-Verwendung

Im ersten Block kann die Verwendung der 7 Sensor-Pins definiert werden. Aktuell wird lediglich zwischen USAGE_NONE und USAGE_DS18B20 unterschieden. Der Pulszähler ist permanent aktiv, ADC wird momentan nur für AIN unterstützt.

Modbus

Die RS485-Schnittstelle läuft mit 19200 Baud 8N1 - die Baudrate kann über den Define MODBUS_BAUDRATE angepasst werden.

Die Modbus-Adresse muss aktuell zur Kompilierzeit über MODBUS_ADDRESS festgelegt werden. Mit der nächsten Hardware-Revision wird mit an Sicherheit grenzender Wahrscheinlichkeit das Setzen per Dip-Schalter verfügbar sein.

MODBUS_ACT_DURATION legt in 100 µs-Schritten fest, wie lange die Power-LED bei Busaktivität flackern soll.

Die restlichen Defines in diesem Bereich sollten nicht verändert werden.

ADC

Mit ADC0_REF kann die Spannungsreferenz für den ADC konfiguriert werden, diese kann durch ein-/auskommentieren der jeweiligen Zeile verändert werden. Bitte an der Stelle beachten, dass der verbaute Pull-Up lediglich gegen VDD (5 Volt) verschaltet werden kann.

ADC0_ACCUMULATE legt fest, wie viele Messungen für eine Rückgabe des ADC-Werts durchgeführt werden. Leichtes Signalrauschen vorausgesetzt kann dies die Auflösung erhöhen. Entsprechend der Akkumulation (1/2/4/8/16/32/64) steigt der maximal mögliche Wert der Ausgabe und die Samplingdauer erhöht sich dementsprechend. Die zu messenden Kanäle sind Kommasepariert über ADC_SCANCHANNELS zu definieren, wird aktuell jedoch noch nicht "offiziell" unterstützt.

Encoder

Über ROTENC_UPDATE_INTERVAL wird der Ausleseintervall des AS5600 in 100 µs-Schritten definiert. Mti den folgendenn Defines mit Präfiy ROTENC_... kann das Verhalten des Sensors beeinflusst werden, bitte dazu das Datenblatt lesen.

Pulsezähler

Last but not least, der Pulszähler. In diesem Bereich kann der Intervall der Auslesungen (PULSECOUNTER_TICK_INTERVAL) in 100 µs-Schritten eingestellt werden.

Mit PULSECOUNTER_L_THRESHOLD wird eingestellt, wie viele dieser Ticks das entsprechende Signal low sein muss, um einen Puls zu qualifizieren.

Mit

#define PULSECOUNTER_TICK_INTERVAL 5
#define PULSECOUNTER_L_THRESHOLD 20

wird also ein Intervall von 500 µs für das Sampling eingestellt, dementsprechend muss ein Signal mindestens 20 Samples zusammenhängend als low erkannt worden sein (also 10 ms), um registriert zu werden.

Anschluss

Easy. Pinöpel an den Federkraftklemmen drücken, Leitung (egal ob starr oder flexibel - auch ohne Aderendhülse) bis 0,82 mm² reinschieben, fertig.

Da Masse und Versorgung für die Sensoren geteilt wird, kann es dann wie folgt aussehen:

Fremdspannung wird nicht toleriert, also am besten nur durch das Modul versorgte Peripherie verwenden.

Kommunikation

In Python lässt sich die Kommunikation recht einfach mit pymodbus bewerkstelligen.

Für den Einstieg ein einfaches Beispiel:

from pymodbus.client import ModbusSerialClient
import time

client = ModbusSerialClient("COM2", baudrate=19200)
client.connect()

addr = 10 # modbus address of the device

# write holding register to initiate readout
client.write_register(0, 1, addr) 

# wait for the conversion to be done
time.sleep(1.1)

# read the input registers for temperature (and readout counter)
ans = client.read_input_registers(0, 8, addr)

# divide the temperature readings to °C (only works for positive temperatures)
print([x / 100 for x in ans.registers[0:6]])

print(f"the temperature sensors were read {ans.registers[7]} times")

Startet die Konversion der Temperaturfühler und gibt die Messwerte sowie die Anzahl der Messungen aus.

Nochmal einen Schritt zurück - die Register sind wie folgt definiert:

Holding-Register

Das Set an Holding-Registern ist sehr überschaubar:

Register B15 B14 B13 B12 B11 B10 B09 B08 B07 B06 B05 B04 B03 B02 B01 B00
0x0000 DS18B20-Status

Über die zwei niederwertigsten Bits von Register 0x0000 kann über das Schreiben des Wertes 0x1 die Konversion der DS18B20 gestartet und der Status des Vorgangs ausgelesen werden:

Wert Status
0x0 Idle, noch keine Konversion durchgeführt
0x1 Konversion starten
0x2 Konversion läuft
0x3 Konversion abgeschlossen, Werte sind verfügbar

Während die Konversion läuft, leuchtet die rote Status-LED.

Input-Register

Register Funktion B15 B14 B13 B12 B11 B10 B09 B08 B07 B06 B05 B04 B03 B02 B01 B00
0x0000 Temperatursensoren Temperatursensor S1
0x0001 Temperatursensor S2
0x0002 Temperatursensor S3
0x0003 Temperatursensor S4
0x0004 Temperatursensor S5
0x0005 Temperatursensor S6
0x0006 Temperatursensor S7
0x0007 Auslesezähler Temperatursensoren
0x0008 Encoder Sensor nicht gefunden Magnet erkannt Signal zu schwach Signal zu stark Magnetsensor Gain
0x0009 Winkel
0x000A Signalstärke
0x000B ADC AIN
0x000C Pulszähler Negative Pulse S1
0x000D Negative Pulse S2
0x000E Negative Pulse S3
0x000F Negative Pulse S4
0x0010 Negative Pulse S5
0x0011 Netative Pulse S6
0x0012 Netative Pulse S7
0x0013 Negative Pulse AIN

Temperatursensoren

Für einen noch nicht ausgelesenen Temperatursensor wird als Temperaturwert 0x8000 zurückgegeben, für das 85 °C-Problem ist keine Fehlerbehandlung integriert.

Der Auslesezähler gibt an, wie oft seit dem Neustart des Mikrocontrollers die Sensoren abgefragt wurden. Der Wert kann überlaufen.

Zum Auslesen der Temperaturen gibt es zwei Vorgehensweisen: "Starten und warten" oder "Starten und anstupsen".

Starten und warten wurde bereits im Beispiel weiter oben gezeigt, wenn auch etwas unvollständig - für negative Temperaturen muss der zurückgegebene Wert entsprechend des Zweierkomplements umgewandelt werden. Vorteil dieser Methode ist, dass die Buslast niedrig gehalten wird. Da die Konversionsdauer fest ist ist das vermutlich auch die sinnvollste Variante.

Kann man es machen wie die Kinder im Auto: "sind wir schon da?" - im Endeffekt muss hier genauso gewartet werden, aber halt anders.

Beispielcode:

def twos_comp(val, bits):
    if (val & (1 << (bits - 1))) != 0:
        val = val - (1 << bits)
    return val 

def read_temps_poll(client, addr):
    client.write_register(0x0000, 1, addr)
    done = False
    for _ in range(0, 15):
        hodl = client.read_holding_registers(0x0000, 1, addr)

        if hodl.registers[0] == 0:
            raise Exception("Conversion didn't start")
        if hodl.registers[0] == 3:
            done = True
            break
        time.sleep(0.1)
    if not done:
        raise Exception("Conversion didn't finish in time")
    
    ans = client.read_input_registers(0x0000, 7, addr)
    return [None if x == 0x8000 else (twos_comp(x, 16) / 100) for x in ans.registers]

def read_temps_wait(client, addr):
    client.write_register(0x0000, 1, addr)
    time.sleep(1.1)
    
    ans = client.read_input_registers(0x0000, 7, addr)
    return [None if x == 0x8000 else (twos_comp(x, 16) / 100) for x in ans.registers]

Encoder

Das Auslesen des magnetischen Encoders ist sichtlich einfach. Die Werte sind im Endeffekt wie vom Sensor zur Verfügung gestellt. lediglich Bit 15 wird von der Firmware selbst erzeugt, wenn nicht auf das I²C-Device zugegriffen werden kann. Bitte beachten, dass es auch Winkeldaten gibt, wenn kein Magnet erkannt wurde. Dementsprechend rauschen die Werte.

ans = client.read_input_registers(0x0008, 3, addr)

status = ans.registers[0] >> 8
if status & 8 != 0:
    print("could not read sensor or sensor not detected")
else:
    print(f"Angle: {ans.registers[1]}")
    print(f"Gain: {ans.registers[0] & 0xFF}")
    print(f"Magnitude: {ans.registers[2]}")
    if status & 4 != 0:
        print("magnet detected")
    if status & 2 != 0:
        print("magnet too weak")
    if status & 1 != 0:
        print("magnet too strong")

ADC

Das Auslesen des ADC ist sichtlich einfach:

ans = client.read_input_registers(0x000B, 1, addr)
print(ans.registers[0])

Je nach Konfiguration des Akkumulation ist der Wertebereich von 0 bis 1023/2046/4092/...

Pulszähler

Für die Pulszähler kann einfach das vorherige Beispiel herangezogen und die Register-Adresse angepasst werden.

Gehäuse

Kommt noch, wenn es besser ist.

Fehler/Verbesserungspotenzial

  • Der Dip-Schalter für die Adresswahl sollte richtig angeschlossen werden bzw. das Ändern der Adresse per Modbus erfolgen können
  • Die Konfiguration der IOs per config.h ist noch unvollständig. Persistente Konfiguration per Modbus wäre noch etwas besser
  • Die Platzierung der Schraublöcher ist in Hinblick auf die Leitungen unglücklich

Downloads

Alle Projektdaten können auf Github heruntergeladen werden.