[Anfänger] Proteus Ecometer - Ölstandsanzeige

Stellt hier eure Projekte vor.
Internetseiten, Skripte, und alles andere bzgl. Python.
Antworten
mafl
User
Beiträge: 4
Registriert: Sonntag 26. April 2020, 14:22

Hallo,
ich habe mein erstes Python-Script erstellt und hätte gerne die Meinung und Verbesserungsvorschläge der Python-Könner.

Zur Ausgangssituation: Ich möchte meinen Ölstandssensor (https://www.proteus.at/em-27437.htm) in openHab einbinden. Leider gibt es dazu kein passendes Modul. Also habe ich mir einen Raspberry Zero geholt und den Monitor dort angeschlossen. Der Monitor sendet prinzipiell jede Stunde (kann variieren) die Sensordaten. Ich habe erst mit einem Bashscript versucht die Daten abzufragen und an openHab zu schicken, was prinzipiell auch funktioniert, aber mir fehlte dann ein gewisser Komfort bzw. Überprüfungsmöglichkeiten. Also versuche ich es jetzt mit Python.
Das Script funktioniert, aber ich würde das gerne auch anderen zur Verfügung stellen und da möchte ich sicher sein, dass es korrekt und sauber ist.
Deshalb bin ich um jeden Input froh, der das Script verbessert.
Hab mir die ganzen Infos aus verschiedenen Tutorials zusammengesucht und deshalb denke ich, dass es einiges an Overhead und Verbesserungsmöglichkeiten gibt.

Grundsatzüberlegung(en):
  • Das Script startet mit dem Booten des Raspberry
  • Die Daten des Monitors sollen ständig empfangen werden können
  • Die Daten sollen nach einem Ausfall des Raspberry immer noch vorhanden sein
  • Die Daten werden mit einer REST-API im JSON-Format zur Verfügung gestellt und können jederzeit abgerufen werden
Vielen Dank für eure Mühen
Martin

Code: Alles auswählen

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from flask import Flask, request, jsonify, render_template
from waitress import serve
import re
import pickle
import serial
import datetime
import logging
import threading

# Define data file
ecometer_data_file = '/home/pi/eco_data.pkl'

# Define Logger
logger = logging.getLogger('Ecometer_Log')
logger.setLevel(logging.DEBUG)
fh = logging.FileHandler('ecometer.log')
fh.setLevel(logging.DEBUG)
logger.addHandler(fh)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
logger.addHandler(fh)

# Define Webserver
app = Flask(__name__)

# Thread for reading the data
def ecometer_reading_data():
    logger.info('Start Thread ecometer_reading_data')

    # Port of Ecometer-Device
    ecometer_serial = '/dev/ttyUSB0'

    # Open serial device for reading, it is 115200 baud, 8N1
    # https://sarnau.info/communication-protocol-of-the-proteus-ecometer-tek603/
    ser = serial.Serial(ecometer_serial, 115200)

    while True:
        # make sure that are no old bytes left in the input buffer
        ser.reset_input_buffer()

        logger.info('Thread: Waiting for Data...')

        # FYI: the devices sends one package with 22 Bytes per hour(?) or at irregular intervals
        # Convert the Bytes in a Hex-String for extracting the needed values (Don't know how to make it directly)
        hex_string = ser.read(22).hex()

        # each packet starts with 2 Bytes, which are 'SI' (in HEX = '5349' on Position 0-4)
        if hex_string[0:4] != '5349': # Proteus Ecometer detected?
            continue

        logger.info('Thread: Data is read in')

        # The next 2 Bytes are the Length of the complete Package (16 bit, big-endian)
        # The next 1 Byte is a Command (1: data send to the device, 2: data received from the device)
        # The next 1 Byte are Flags (bit 0: set the clock (hour/minutes/seconds) in the device on upload, bit 1: force reset the device (set before an update of the device),
        #                            bit 2: a non-empty payload is send to the device, bit 3: force recalculate the device (set on upload after changing the Sensor Offset, Outlet Height or the lookup table)
        #                            bit 4: live data received from the device, bit 5: n/a, bit 6: n/a, bit 7: n/a)

        # The next 3 Bytes are the Time from Ecometer: 1 Byte = Hour, 1 Byte = Minute, 1 Byte = Minute (in HEX = character 12-17)
        time = '%02d:%02d:%02d' % (int(hex_string[12:14], 16), int(hex_string[14:16], 16), int(hex_string[16:18], 16))
        time_dict = {
            'id' : 0,
            'item' : 'Time',
            'value' : time
        }

        # The next 2 Bytes are the EEPROM Start (16 bit, big-endian) – unused in live data
        # The next 2 Bytes are the EEPROM End (16 bit, big-endian)

        # The next 1 Byte are the Temperature in Farenheit (in HEX = character 26-27)
        temp_f = int(hex_string[26:28], 16)
        tempF_dict = {
            'id' : 1,
            'item' : 'Temp_F',
            'value' : temp_f
        }
        # Calculating the Temp in °C
        temp_c = ((temp_f - 40 - 32) / 1.8)
        tempC_dict = {
            'id' : 2,
            'item' : 'Temp_C',
            'value' : temp_c
        }

        # The next 2 Bytes are the Sensor Level in cm (Ullage) (16-bit, big-endian) (in HEX = character 28-31)
        ullage = int(hex_string[28:32], 16)
        ullage_dict = {
            'id' : 3,
            'item' : 'Ullage',
            'value' : ullage
        }

        # The next 2 Bytes are the Usable Level (Available Qantity) in Liter (16-bit, big-endian) (in HEX = character 32-35)
        usableLevel = int(hex_string[32:36], 16)
        usableLevel_dict = {
            'id' : 4,
            'item' : 'UseableLevel',
            'value' : usableLevel
        }

        # The next 2 Bytes are the Totale Capacity in Liter (16-bit, big-endian) (in HEX = character 36-39)
        capacity = int(hex_string[36:40], 16)
        capacity_dict = {
            'id' : 6,
            'item' : 'UseableCapacity',
            'value' : capacity
        }

        # Calculating the available Qantity in %
        usablePercent = usableLevel / capacity * 100.01
        usablePercent_dict = {
            'id' : 5,
            'item' : 'UseablePercent',
            'value' : usablePercent
        }

        # Crdate (When was the data generated?)
        now = datetime.datetime.now()
        crdate_dict = {
            'id' : 7,
            'item' : 'Timestamp',
            'value' : now.timestamp()
        }

        eco_tuple = (time_dict,tempF_dict,tempC_dict,ullage_dict,usableLevel_dict,usablePercent_dict,capacity_dict,crdate_dict)
        logger.info('Thread: Data ready to save')
        # Pickle the Tuple
        eco_file = open(ecometer_data_file,"bw")
        logger.info('Thread: Storage file opened for writing')
        pickle.dump(eco_tuple,eco_file)
        logger.info('Thread: Data has been saved')
        eco_file.close()
        logger.info('Thread: Storage file closed')
        # Deleting the Tuple for new Measurement
        del eco_tuple
        logger.info('Thread: Storage data discarded')

    logger.info('End Thread ecometer_reading_data')

# Index-Seite
@app.route("/", methods=['GET'])
def home():
    return render_template("home.html")

# REST-API
@app.route('/rest/item/<id>', methods=['GET'])
def api_id(id):
    # Check if an ID was provided as part of the URL.
    # If ID is provided, assign it to a variable.
    # If no ID is provided, display an error in the browser.
    if int(id) in range(8):
        id = int(id)
    else:
        return "Error: Die gewählte ID wird nicht unterstützt. Gültige IDs sind von 0 bis 7."

    # Read Pickle File for Data
    eco_file = open(ecometer_data_file,"rb")
    logger.info('Webserver: Storage file opened for reading')
    ecometer_items = pickle.load(eco_file)
    logger.info('Webserver: Data has been read')

    # Create an empty list for our results
    results = []

    # Loop through the data and match results that fit the requested ID.
    # IDs are unique, but other fields might return many results
    for ecometer_item in ecometer_items:
        if ecometer_item['id'] == id:
            results.append(ecometer_item)

    # Use the jsonify function from Flask to convert our list of
    # Python dictionaries to the JSON format.
    return jsonify(results)

# 404
@app.errorhandler(404)
def page_not_found(e):
    return "<h1>404</h1><p>Die angeforderte Seite gibt es nicht.</p>", 404

# Webserver starten
if __name__ == '__main__':
    # Start Threat for reading Data
    ecometer_get_data = threading.Thread(target=ecometer_reading_data)
    logger.info('Main: Thread is called')
    ecometer_get_data.start()
    serve(app, host='0.0.0.0', port=5000)
Benutzeravatar
__blackjack__
User
Beiträge: 13003
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@mafl: Das `re`-Modul und `request` aus `flask` werden importiert, aber nirgends verwendet.

Namen werden in Python klein_mit_unterstrichen geschrieben. Ausnahmen sind Konstanten (KOMPLETT_GROSS) und Klassen (MixedCase).

Bei etwas das `*_file` heisst, erwartet der Leser ein Dateiobjekt, also etwas das beispielsweise eine `read()`- oder `write()`-Methode und eine `close()`-Methode hat. Eine Datei ist etwas anderes als ein Datei*name*.

Namen sollte man nicht kryptisch abkürzen. Wenn man `file_handler` meint, sollte man nicht `fh` schreiben.

Es macht wenig Sinn den gleichen `FileHandler` *zweimal* auf dem Logger zu setzen.

Auf Modulebene sollte nur Code stehen der Konstanten, Funktionen, und Klassen definiert. Ausnahmen sind hier der Logger und `app`, letzteres weil das von Flask so vorgesehen ist. Aber der Code im ``if __name__ …``-Guard sollte in einer Funktion verschwinden.

Threads würde ich immer als `daemon` starten, sonst ist es schwieriger das Programm abzubrechen.

Bei der seriellen Schnittstelle sollte man sicherstellen, dass die auch wieder geschlossen wird. `Serial`-Objekte sind Kontextmanager, können also mit ``with`` verwendet werden. `ser` ist kein guter Name. Was soll das bedeuten?

Das mit der Darstellung als Hexadezimalwerte ist ein unschöner Hack. Da würde man einfach mit dem `bytes`-Objekt arbeiten das `read()` liefert.

``%`` würde ich in neuem Code nicht mehr zur Zeichenkettenformatierung verwenden wenn es dafür keinen guten Grund gibt. Es gibt die `format()`-Methode und ab Python 3.6 f-Zeichenkettenliterale.

Grunddatentypen haben nichts in Namen zu suchen. Das ändert man im Laufe der Entwicklung gerne mal und dann hat man entweder irreführende falsche Namen im Programm, oder muss die überall anpassen.

Die Datenstruktur ist auch falsch. Da soll ja später über die ID zugegriffen werden, also sollte man das nicht als Tupel speichern wo man dann linear nach der ID suchen muss, sondern als Wörterbuch das IDs auf Werte abbildet, beziehungsweise als Liste in die man die ID als Index verwenden kann.

Dateien sollte man wo es geht mit der ``with``-Anweisung öffnen, dann kann man das schliessen der Datei nicht vergessen. Der Dateimodus "bw" sollte wohl "wb" heissen.

``del`` löscht nicht das Tupel sondern den *Namen*. Es kann sein, dass dadurch auch zeitnah das Tupel gelöscht wird. Es kann aber auch sein, dass das nicht passiert. ``del`` ist nicht zur Speicherverwaltung da, das macht also keinen Sinn.

Das id in der URL eine ganze Zahl sein soll, gibt man gleich in der URL an, dann braucht man das in der Funktion dann nicht mehr umwandeln.

`results` ist eine Liste die immer genau ein Element enhält, womit das eine ziemlich sinnlose Datenstruktur ist.

Ungetestet:

Code: Alles auswählen

#!/usr/bin/env python3
import datetime
import logging
import pickle
import threading

import serial
from flask import Flask, jsonify, render_template
from waitress import serve

ECOMETER_DATA_FILENAME = "/home/pi/eco_data.pkl"
ITEM_NAMES = [
    "Time",
    "Temp_F",
    "Temp_C",
    "Ullage",
    "UseableLevel",
    "UseablePercent",
    "UseableCapacity",
    "Timestamp",
]


def setup_logger():
    logger = logging.getLogger("Ecometer_Log")
    logger.setLevel(logging.DEBUG)
    file_handler = logging.FileHandler("ecometer.log")
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(
        logging.Formatter(
            "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
        )
    )
    logger.addHandler(file_handler)
    return logger


LOGGER = setup_logger()

app = Flask(__name__)


def read_ecometer_data():
    LOGGER.info("Start Thread ecometer_reading_data")
    #
    # https://sarnau.info/communication-protocol-of-the-proteus-ecometer-tek603/
    #
    with serial.Serial("/dev/ttyUSB0", 115200) as connection:
        while True:
            #
            # Make sure that are no old bytes left in the input buffer.
            #
            connection.reset_input_buffer()
            LOGGER.info("Thread: Waiting for Data...")
            #
            # The devices sends one package with 22 Bytes per hour(?) or at
            # irregular intervals.
            #
            data = connection.read(22)
            if data.startswith(b"SI"):
                LOGGER.info("Thread: Data is read in")
                #
                # The next 2 Bytes are the Length of the complete Package (16
                # bit, big-endian)
                #
                # The next 1 Byte is a Command (1: data send to the device, 2:
                # data received from the device)
                #
                # The next 1 Byte are Flags:
                #
                #   bit 0: set the clock (hour/minutes/seconds) in the device on
                #   upload,
                #
                #   bit 1: force reset the device (set before an update of the
                #   device),
                #
                #   bit 2: a non-empty payload is send to the device,
                #
                #   bit 3: force recalculate the device (set on upload after
                #   changing the Sensor Offset, Outlet Height or the lookup
                #   table)
                #
                #   bit 4: live data received from the device,
                #
                #   bit 5: n/a, bit 6: n/a, bit 7: n/a.
                #
                # The next 3 Bytes are the Time from Ecometer: 1 Byte = Hour, 1
                # Byte = Minute, 1 Byte = Minute (in HEX = character 12-17)
                #
                time = f"{data[6]:02d}:{data[7]:02d}:{data[8]:02d}"
                #
                # The next 2 Bytes are the EEPROM Start (16 bit, big-endian) –
                # unused in live data.
                #
                # The next 2 Bytes are the EEPROM End (16 bit, big-endian).
                #
                # The next 1 Byte is the temperature in Fahrenheit.
                #
                fahrenheit = data[13]
                degrees_celsius = (fahrenheit - 32) / 1.8
                #
                # The next 2 Bytes are the Sensor Level in cm (Ullage) (16-bit,
                # big-endian).
                #
                ullage = data[14] * 256 + data[15]
                #
                # The next 2 Bytes are the Usable Level (Available Qantity) in
                # Liter (16-bit, big-endian)
                #
                usable_level = data[16] * 256 + data[17]
                #
                # The next 2 Bytes are the Totale Capacity in Liter (16-bit,
                # big-endian)
                #
                capacity = data[18] * 256 + data[19]
                usable_percent = usable_level / capacity * 100.01

                LOGGER.info("Thread: Data ready to save")
                with open(ECOMETER_DATA_FILENAME, "bw") as eco_file:
                    LOGGER.info("Thread: Storage file opened for writing")
                    pickle.dump(
                        (
                            time,
                            fahrenheit,
                            degrees_celsius,
                            ullage,
                            usable_level,
                            usable_percent,
                            capacity,
                            datetime.datetime.now(),
                        ),
                        eco_file,
                    )
                    LOGGER.info("Thread: Data has been saved")
                LOGGER.info("Thread: Storage file closed")


@app.route("/", methods=["GET"])
def home():
    return render_template("home.html")


@app.route("/rest/item/<int:id_>", methods=["GET"])
def api_id(id_):
    if not 0 <= id_ < len(ITEM_NAMES):
        return (
            f"Error: Die gewählte ID wird nicht unterstützt."
            f" Gültige IDs sind von 0 bis {len(ITEM_NAMES)}."
        )

    with open(ECOMETER_DATA_FILENAME, "rb") as eco_file:
        LOGGER.info("Webserver: Storage file opened for reading")
        ecometer_values = pickle.load(eco_file)
        LOGGER.info("Webserver: Data has been read")

    return jsonify(
        {"id": id_, "item": ITEM_NAMES[id_], "value": ecometer_values[id_]}
    )


@app.errorhandler(404)
def page_not_found(_):
    return "<h1>404</h1><p>Die angeforderte Seite gibt es nicht.</p>", 404


def main():
    thread = threading.Thread(target=read_ecometer_data, daemon=True)
    LOGGER.info("Main: Thread is called")
    thread.start()
    serve(app, host="0.0.0.0", port=5000)


if __name__ == "__main__":
    main()
Der Code hat allerdings noch zwei massive Fehler: 1. Wenn die Datei gleichzeitig geschrieben und gelesen wird, passiert Murks. Und der Code garantiert nicht das die Daten die man für die IDs abfragt auch alle zur gleichen Messung gehören. Das ist beides ziemlich kaputt.
“Most people find the concept of programming obvious, but the doing impossible.” — Alan J. Perlis
Sirius3
User
Beiträge: 17710
Registriert: Sonntag 21. Oktober 2012, 17:20

Zum Lesen von Byte-Strukturen gibt es das `struct`-Modul.
Damit vereinfacht sich das Lesen deutlich:

Code: Alles auswählen

data = connection.read(22)
(magic, _length, _command, _flags,
    hour, minute, second, _start, _end,
    temperature, ul_lage, usable_level, capacity, usable_percent
) = struct.unpack(">2shbb3bhhb4h", data)
if magic == "SI":
    result = {
        'Time': '%02d:%02d:%02d' % (hour, minute, second),
        'Temp_F': temperature,
        'Temp_C': (temperature - 40 - 32) / 1.8,
        'Ullage': ul_lage,
        'UseableLevel': usable_level,
        'UseableCapacity': capacity,
        'UseablePercent': usable_level / capacity * 100.01,
        'Timestamp': datetime.datetime.now().timestamp(),
    }
    with open(ECOMETER_DATA_FILENAME, "bw") as eco_file:
        pickle.dump(result)
mafl
User
Beiträge: 4
Registriert: Sonntag 26. April 2020, 14:22

@Sirius3: Vielen Dank für den Hinweis. Genau sowas habe ich gesucht aber leider nicht gefunden. Deshalb auch der umständliche Hack über HEX
@__blackjack__: Danke für die Anmerkungen. Genau das wollte ich. Und auch so verständlich, dass ich sie als Anfänger verstehe.

Ich hab das jetzt umgesetzt und bin am testen.

Es gibt allerdings noch Fragen:
Ich speichere jetzt "nur" die Werte in einer Liste (so wie __blackjack__ vorgeschlagen). Meine Überlegung war (auch wie Sirius3) ein Wörterbuch zu speichern, damit ich die Daten in der Datei einfacher lesen kann. Da das Wörterbuch aber unsortiert ist und keinen Index hat habe ich den dazu geschrieben. Ist das prinzipiell schon falsch? Oder verstehe ich das
....sondern als Wörterbuch das IDs auf Werte abbildet....
nicht? Ein Wörterbuch hat doch keine IDs, oder? Lieber wäre mir eigentlich schon eine Wörterbuch-Variante. Spricht da strukturtechnisch was dagegen?

Spricht was gegen folgende Formatierung "struct.unpack(">2sh5b2hb4h", data) " anstatt "struct.unpack(">2shbb3bhhb4h", data)". Finde ich persönlich logischer.

Dann noch die zwei grundsätzlichen Probleme die __blackjack__ anspricht:
Und der Code garantiert nicht das die Daten die man für die IDs abfragt auch alle zur gleichen Messung gehören.
Das verstehe ich nicht. Da ich ja immer nur einen kompletten Datensatz in einem Durchgang speichere und keine "Archiv-" oder "Verlaufsdaten" habe kann es hier doch auch keine Inkosistenzen geben? Wo sollen die herkommen? Und pickle erwartet ja auch immer ein abgeschlossenes Objekt, sonst wird ja nicht gespeichert. Entweder alle gehören zusammen, oder sie sind unvollständig (wegen Problem2: Gleichzeitiger Zugriff zum Lesen und Schreiben der Datei). Vielleicht kann mich da noch jemand erleuchten?
Wenn die Datei gleichzeitig geschrieben und gelesen wird, passiert Murks.
Das kann prinzipiell ich nachvollziehen. Aber gibt es da auch eine Lösung? Sowas wie: Wenn Datei gerade offen, dann warte mit lesen bis sie wieder geschlossen ist. Passiert das mit der "with" Anweisung bzw. durch pickle (siehe oben) nicht automatisch?
Da bin ich gerade ziemlich verwirrt.

Der Fall wird bei mir zwar höchst selten vorkommen und ist für mich eigentlich nicht relevant: Der Ecometer sendet in der Regel nur alle Stunden (bei Öl). Bei massiven Schwankungen (beim Befüllen) auch öfter, aber das ist sowieso eine Ausnahmesituation. Und ich frage den Ölstand ja auch nicht im Minutentakt ab. Ein bis zwei automatische Abfragen pro Tag reichen mir. Notfalls muss ich maximal eine Stunde warten und einen manuelle Abfrage machen und ich hab wieder valide Daten. Bei einer Wasserzisterne kann das natürlich anders sein. Und da ich den Code ja öffentlich zur Verfügung stellen möchte sollte das Problem schon ausgeschlossen werden. Da bin ich auch noch um jede Hilfe und Idee froh und dankbar.

Der Code sieht jetzt so aus:

Code: Alles auswählen

#!/usr/bin/env python3
import datetime
import logging
import pickle
import threading

import serial
import struct
from flask import Flask, jsonify, render_template
from waitress import serve

ECOMETER_DATA_FILENAME = "/home/pi/eco_data.pkl"
ITEM_NAMES = [
    "Time",
    "Temp_F",
    "Temp_C",
    "Ullage",
    "UseableLevel",
    "UseablePercent",
    "UseableCapacity",
    "Timestamp",
]

def setup_logger():
    logger = logging.getLogger("Ecometer_Log")
    logger.setLevel(logging.DEBUG)
    file_handler = logging.FileHandler("ecometer.log")
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(
        logging.Formatter(
            "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
        )
    )
    logger.addHandler(file_handler)
    return logger


LOGGER = setup_logger()

app = Flask(__name__)

def read_ecometer_data():
    LOGGER.info("Start Thread ecometer_reading_data")
    #
    # https://sarnau.info/communication-protocol-of-the-proteus-ecometer-tek603/
    #
    with serial.Serial("/dev/ttyUSB0", 115200) as connection:
        while True:
            #
            # Make sure that are no old bytes left in the input buffer.
            #
            connection.reset_input_buffer()
            LOGGER.info("Thread: Waiting for Data...")
            #
            # The devices sends one package with 22 Bytes per hour(?) or at
            # irregular intervals.
            #
            data = connection.read(22)
            (magic, _length, _command, _flags,
                hour, minute, second, _start, _end,
                temperature, ul_lage, usable_level, capacity, _crc
            ) = struct.unpack(">2sh5b2hb4h", data) 
            if magic == "SI":
                result = (
                    f"{hour:02d}:{minute:02d}:{second:02d}",
                    temperature,
                    (temperature - 40 - 32) / 1.8,
                    ul_lage,
                    usable_level,
                    capacity,
                    usable_level / capacity * 100.01,
                    datetime.datetime.now().timestamp(),
                )
                LOGGER.info("Thread: Data ready to save")
                with open(ECOMETER_DATA_FILENAME, "wb") as eco_file:
                    LOGGER.info("Thread: Storage file opened for writing")
                    pickle.dump(result)
                    LOGGER.info("Thread: Data has been saved")
                LOGGER.info("Thread: Storage file closed")


@app.route("/", methods=["GET"])
def home():
    return render_template("home.html")


@app.route("/rest/item/<int:id_>", methods=["GET"])
def api_id(id_):
    if not 0 <= id_ < len(ITEM_NAMES):
        return (
            f"<h1>Fehler</h1>"
            f"<p>Die gewählte ID wird nicht unterstützt. Gültige IDs sind von 0 bis {len(ITEM_NAMES)}.</p>"
        )

    with open(ECOMETER_DATA_FILENAME, "rb") as eco_file:
        LOGGER.info("Webserver: Storage file opened for reading")
        ecometer_values = pickle.load(eco_file)
        LOGGER.info("Webserver: Data has been read")

    return jsonify(
        {"id": id_, "item": ITEM_NAMES[id_], "value": ecometer_values[id_]}
    )


@app.errorhandler(404)
def page_not_found(_):
    return "<h1>404</h1><p>Die angeforderte Seite gibt es nicht.</p>", 404


def main():
    thread = threading.Thread(target=read_ecometer_data, daemon=True)
    LOGGER.info("Main: Thread is called")
    thread.start()
    serve(app, host="0.0.0.0", port=5000)


if __name__ == "__main__":
    main()   
Benutzeravatar
__blackjack__
User
Beiträge: 13003
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@mafl: Logischer finde ich die `struct`-Zeichenkette von Sirius3, weil die eben die Logik dahinter besser wiederspiegelt. Also beispielsweise nicht "5b" sondern "bb3b" weil das zwei Bytes mit einer unterschiedlichen Bedeutung sind, gefolgt von 3 Bytes die zusammen die Uhrzeit ergeben. Deine Variante ist IMHO nicht logisch sondern kompakt. Kann man natürlich auch machen.

Das erste Konsistenzproblem entsteht nicht beim schreiben der Daten. Da werden immer alle zusammen geschrieben. Aber wenn Du die Einzelwerte per ID über's Netz abfragst, gibt es keine Garantie das beispielsweise die Abfrage der Zeit und die Abfrage der Temperatur tatsächlich aus der gleichen Messung stammen. Du kannst die Zeit abfragen, dann passiert eine Messung und schreibt einen kompletten neuen Datensatz, und dann kannst Du die Temperatur abfragen, aus dem neuen Datensatz in dem eine andere Zeit steht. Das weisst Du aber nicht, und kannst das auch überhaupt gar nicht feststellen von aussen.

Bei dem gleichzeitigen Lesen und Schreiben der Datei ist die übliche Lösung das man erst in eine temporäre Datei schreibt und die am Ende umbenennt. So hat man man immer entweder die komplette alte Datei oder die komplette neue Datei unter dem nicht-temporären Namen. Oder man speichert in eine Datenbank die Transaktionen kann. Sqlite3 zum Beispiel. Oder man spart sich das mit der Datei ganz, beziehungsweise liest den Inhalt nur bei Programmstart und hält die Daten ansonsten im Speicher vor.
“Most people find the concept of programming obvious, but the doing impossible.” — Alan J. Perlis
mafl
User
Beiträge: 4
Registriert: Sonntag 26. April 2020, 14:22

@__blackjack__: Vielen Dank für die Hilfe. Und ja du hast recht. Die Datei bräuchte ich nur beim Programmstart. Aber alle meine Versuche die Daten aus der Endlosschleife zu bekommen ohne diese zu verlassen sind gescheitert. Möglicherweise sehe ich den Wald auch vor lauter Bäumen nicht.
mafl
User
Beiträge: 4
Registriert: Sonntag 26. April 2020, 14:22

So jetzt Version 2.
Vielleicht kann mir jemand auch dazu Verbesserungsvorschläge geben. Vielen Dank.

Keine Ahnung ob das mit der Variable "ecometer_result" so gelöst werden kann/soll. Aber ich weiß einfach nicht, wie ich das anders lösen könnte. Vorschläge werden gerne genommen.
Die Datenstruktur habe ich bewusst so gewählt. Dann kann ich die Datei "lesen" und einfach alle Parameter übergeben. Spricht da was dagegen?
Das exzessive Logging werde ich am Ende noch ein bisschen ausdünnen.

Code: Alles auswählen

#!/usr/bin/env python3
import datetime
import logging
import pickle
import threading
import os

import serial
import struct
from flask import Flask, jsonify, render_template
from waitress import serve

# 
# Hier die entsprechende Schnittstelle definieren
#
ECOMETER_SERIAL_PORT = "/dev/ttyUSB0"

#
# Ab hier muss nichts mehr geändert werden
#
ECOMETER_LOG_FILENAME = "ecometer.log"
ECOMETER_DATA_FILENAME = "eco_data.pkl"
ITEM_NAMES = [
    "Time",
    "Temp_F",
    "Temp_C",
    "Ullage",
    "UseableLevel",
    "UseableCapacity",
    "UseablePercent",
    "Timestamp",
]

def setup_logger():
    logger = logging.getLogger("Ecometer_Log")
    logger.setLevel(logging.DEBUG)
    file_handler = logging.FileHandler(ECOMETER_LOG_FILENAME)
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(
        logging.Formatter(
            "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
        )
    )
    logger.addHandler(file_handler)
    return logger

LOGGER = setup_logger()

app = Flask(__name__)

class MyEcometer(threading.Thread):
    ecometer_result = []
    
    def __init__(self):
        threading.Thread.__init__(self)
    
    def get_ecometer_data(self):
        LOGGER.info("MyEcometer - get_ecometer_data(): Function starts")
        if os.path.getsize(ECOMETER_DATA_FILENAME) > 0:
            with open(ECOMETER_DATA_FILENAME, "rb") as eco_file:
                LOGGER.info("MyEcometer - get_ecometer_data(): Storage file opened for reading")
                ecometer_result = pickle.load(eco_file)
                LOGGER.info("MyEcometer - get_ecometer_data(): Data has been read")
            LOGGER.info("MyEcometer - get_ecometer_data(): Update global ecometer_result")
            self.ecometer_result.extend(ecometer_result)
        else:
            LOGGER.info("MyEcometer - get_ecometer_data(): Storage file is empty")

    def run(self):
        LOGGER.info("MyEcometer - run(): Start Thread")
        #
        # https://sarnau.info/communication-protocol-of-the-proteus-ecometer-tek603/
        #
        with serial.Serial(ECOMETER_SERIAL_PORT, 115200) as connection:
            while True:
                #
                # Make sure that are no old bytes left in the input buffer.
                #
                connection.reset_input_buffer()
                LOGGER.info("MyEcometer - run(): Waiting for Data...")
                #
                # The devices sends one package with 22 Bytes per hour(?) or at
                # irregular intervals.
                #
                # Each packet starts with 2 Bytes, which are 'SI' (in HEX = '5349' on Position 0-4)
                # The next 2 Bytes are the Length of the complete Package (16 bit, big-endian)
                # The next 1 Byte is a Command (1: data send to the device, 2: data received from the device)
                # The next 1 Byte are Flags (bit 0: set the clock (hour/minutes/seconds) in the device on upload, bit 1: force reset the device (set before an update of the device),
                #                            bit 2: a non-empty payload is send to the device, bit 3: force recalculate the device (set on upload after changing the Sensor Offset, Outlet Height or the lookup table)
                #                            bit 4: live data received from the device, bit 5: n/a, bit 6: n/a, bit 7: n/a)
                # The next 3 Bytes are the Time from Ecometer: 1 Byte = Hour, 1 Byte = Minute, 1 Byte = Minute (in HEX = character 12-17) 
                # The next 2 Bytes are the EEPROM Start (16 bit, big-endian) – unused in live data
                # The next 2 Bytes are the EEPROM End (16 bit, big-endian)
                # The next 1 Byte are the Temperature in Farenheit (in HEX = character 26-27)
                # The next 2 Bytes are the Sensor Level in cm (Ullage) (16-bit, big-endian) (in HEX = character 28-31)
                # The next 2 Bytes are the Usable Level (Available Qantity) in Liter (16-bit, big-endian) (in HEX = character 32-35)
                # The next 2 Bytes are the Totale Capacity in Liter (16-bit, big-endian) (in HEX = character 36-39)
                # The last 2 Bytes are the  CRC16 (16 bit, big-endian)
                #
                data = connection.read(22)
                LOGGER.info("MyEcometer - run()): Data was recived")
                LOGGER.debug("data: %s", data)
                (magic, _length, _command, _flags,
                    hour, minute, second, _start, _end,
                    temperature, ul_lage, usable_level, capacity, _crc
                ) = struct.unpack(">2shbb3bhhb4h", data) 
                if magic == b'SI':
                    LOGGER.info("MyEcometer - run()): Data result is created")
                    result = [
                        {
                            'name' : ITEM_NAMES[0],
                            'value' : f"{hour:02d}:{minute:02d}:{second:02d}"
                        },
                        {
                            'name' : ITEM_NAMES[1],
                            'value' : temperature
                        },
                        {
                            'name' : ITEM_NAMES[2],
                            'value' : (temperature - 40 - 32) / 1.8
                        },
                        {
                            'name' : ITEM_NAMES[3],
                            'value' : ul_lage
                        },
                        {
                            'name' : ITEM_NAMES[4],
                            'value' : usable_level
                        },
                        {
                            'name' : ITEM_NAMES[5],
                            'value' : capacity
                        },
                        {
                            'name' : ITEM_NAMES[6],
                            'value' : usable_level / capacity * 100.01
                        },
                        {
                            'name' : ITEM_NAMES[7],
                            'value' : datetime.datetime.now().timestamp()
                        }
                    ]
                    LOGGER.info("MyEcometer - run(): Data ready to save")
                    with open(ECOMETER_DATA_FILENAME, "wb") as eco_file:
                        LOGGER.info("MyEcometer - run(): Storage file opened for writing")
                        pickle.dump(result, eco_file)
                        LOGGER.info("MyEcometer - run(): Data has been saved")
                    LOGGER.info("MyEcometer - run(): Storage file closed")
                    # Make the result available globally
                    LOGGER.info("MyEcometer - run(): Clear global ecometer_result")
                    self.ecometer_result.clear()
                    LOGGER.info("MyEcometer - run(): Update global ecometer_result")
                    self.ecometer_result.extend(result)



@app.route("/", methods=["GET"])
def home():
    LOGGER.info("Flask - Home(): Serving home.html")
    return render_template("home.html")


@app.route('/rest/item/all', methods=['GET'])
def api_all():
    LOGGER.info("Flask - api_all(): Serving JSON ALL")
    my_ecometer = MyEcometer()
    return jsonify(my_ecometer.ecometer_result)


@app.route("/rest/item/<int:id_>", methods=["GET"])
def api_id(id_):
    LOGGER.info("Flask - api_all(): Serving JSON ID")
    if not 0 <= id_ < len(ITEM_NAMES):
        return (
            f"<h1>Fehler</h1>"
            f"<p>Die gewählte ID wird nicht unterstützt. Gültige IDs sind von 0 bis {len(ITEM_NAMES)-1}.</p>"
        )

    my_ecometer = MyEcometer()
    return jsonify(my_ecometer.ecometer_result[id_])


@app.errorhandler(404)
def page_not_found(_):
    LOGGER.info("Flask - page_not_found(): Serving wrong URL")
    return "<h1>404</h1><p>Die angeforderte Seite gibt es nicht.</p>", 404
    

def main():
    LOGGER.info("Initizalize MyEcometer")
    my_ecometer = MyEcometer()
    LOGGER.info("get_ecometer_data is called")
    my_ecometer.get_ecometer_data()
    LOGGER.info("Thread is called")
    my_ecometer.start()
    serve(app, host="0.0.0.0", port=5000)


if __name__ == "__main__":
    main()
Sirius3
User
Beiträge: 17710
Registriert: Sonntag 21. Oktober 2012, 17:20

Du hast eine globale Variable ›MyEcometer.ecometer_result‹, die Du dadurch verschleierst, dass Du unnötigerweise bei jedem Zugriff erst ein Exemplar von MyEcometer erzeugst.
Die Klasse ist auch keine richtige Klasse, weil sie keinen Zustand hat, sondern nur aus zwei Funktionen besteht, die irgendwie lose zusammenhängen.
Das My-Präfix ist unnötig, weil es nichts aussagt.
Ein Fehler ist, dass Du ecometer_result nicht atomar änderst. Es gibt eine kleines Zeitfenster, in dem es leer ist, eine Abfrage liefert also einen Fehler, statt eines Ergebnisses.
Ein zweiter Fehler betrifft das Lesen von der seriellen Schnittstelle. Wenn Du mitten in einem Block anfängst, wirst Du nie wieder an den Anfang kommen, weil Du immer 22 Bytes liest, also den halben ersten Block und dann wartest, bis der halbe zweite Block kommt.

Also, da Du eh globalen Zustand hast, schreibe am besten zwei Funktionen, die diesen globalen Zustand abfragen oder ändern. Schmeiß die Klasse weg.

Code: Alles auswählen

_ecometer_result = []
_ecometer_lock = threading.Lock()
def get_ecometer_result():
    global _ecometer_result
    with _ecometer_lock:
        if not _ecometer_result:
            try:
                with open(ECOMETER_DATA_FILENAME, "rb") as eco_file:
                    _ecometer_result = pickle.load(eco_file)
            except Exception:
                # reading failed, start with empty result
                LOGGER.exception()
        return _ecometer_result

def set_ecometer_result(result):
    global _ecometer_result
    with _ecometer_lock:
        _ecometer_result = result
        try:
            with open(ECOMETER_DATA_FILENAME, "wb") as eco_file:
                pickle.dump(result, eco_file)
        except Exception:
            # writing failed, ignore
            LOGGER.exception()

def serial_read_loop():
    with serial.Serial(ECOMETER_SERIAL_PORT, 115200) as connection:
        while True:
            # Make sure, we find the beginning of a block.
            data = serial.read_until(b'SI', 999)
            if data.endswith(b'SI'):
                data = connection.read(20)
                (_length, _command, _flags,
                    hour, minute, second, _start, _end,
                    temperature, ul_lage, usable_level, capacity, _crc
                ) = struct.unpack(">hbb3bhhb4h", data)
                # TODO: check length, and crc
                ...
                set_ecometer_result(result)
Antworten