DS18B20-Modbus-Adapter
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?
Inhaltsverzeichnis
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.
- DS18B20Modbus v0.1 pcb top assy.png Oberseite (tatsächliche Bestückung)
- DS18B20Modbus v0.1 pcb bot assy.png Oberseite (tatsächliche Bestückung)
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.