diff --git a/configs.py b/configs.py new file mode 100644 index 0000000..6835ac6 --- /dev/null +++ b/configs.py @@ -0,0 +1,16 @@ +configs = { + 'country': 'AT', + 'ntp_host': 'pool.ntp.org', + 'gmt_offset': 1, + 'auto_summertime': True, + 'disable_wifi_powersavingmode': True, + 'api_port': 80, + 'hostname': 'watering', + 'log_housekeeping_days': 7, + 'api_client_ip': '', + 'pulse_duration': 15, #seconds + 'soil_moisture_measure_interval': 20, #minutes + 'soil_dry_value': 11500, #soil is dry above this value + 'check_for_update': 60, #minutes + 'ota_host': 'http://192.168.1.132' #http://ip-of-your-update-host - leave empty to disable + } \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..c67a59d --- /dev/null +++ b/main.py @@ -0,0 +1,405 @@ +import rp2, re, os +import TimeUtils, Logger, Networking, NTP, OTA +import uasyncio as asyncio +import utime as time +import usocket as socket +from machine import ADC, Pin, Timer + +from secrets import secrets +from configs import configs + +rp2.country(configs['country']) +version = "0.8" + +TimeUtils = TimeUtils.TimeUtils() +Logger = Logger.Logger(configs['log_housekeeping_days']) +Networking = Networking.Networking(Logger, secrets['ssid'], secrets['pw']) +NTP = NTP.NTP(Logger) +ota_host = configs['ota_host'] +project_name = "watering" +filenames = ["configs.py", "Logger.py", "main.py", "Networking.py", "NTP.py", "OTA.py", "secrets.py", "TimeUtils.py"] + +boottime = time.time() +pumpRelais = Pin(15, Pin.OUT, value=0) #gpio15 +heartbeatLed = Pin(0, Pin.OUT, value=0) #gpio0 +wifiLed = Pin(1, Pin.OUT, value=0) #gpio1 +autoLed = Pin(2, Pin.OUT, value=0) #gpio2 +pumpLed = Pin(3, Pin.OUT, value=0) #gpio3 +tempSensor = ADC(4) #internal temperature sensor +soilSensor1 = ADC(0) #gpio26 +soilSensor2 = ADC(1) #gpio27 +soilSensor3 = ADC(2) #gpio28 +waterLevelSensor = Pin(5, Pin.IN, Pin.PULL_UP) #gpio5 +pumpState = False +autoState = False +soilDry = False +waterLevel = False +wateringPulseState = False +temperature = 0.0 +lastSoil1 = 0 +lastSoil2 = 0 +lastSoil3 = 0 +lastWateringPulse = 0 + +# Checks if string is integer +def IsInt(possibleint): + try: + int(possibleint) + except: + return False + else: + return True + +# Reboots the Pico W (f.e. in case of an error) +def Reboot(): + Logger.LogMessage("Performing Reboot") + machine.reset() + +# Calculates the uptime in hours and minutes +def Uptime(): + global boottime + seconds = (time.time() - boottime) % (24 * 3600) + hours = seconds // 3600 + seconds %= 3600 + minutes = seconds // 60 + seconds %= 60 + return "%d:%02d" % (hours, minutes) + +# Turns on the watering pump +def WateringOn(): + global pumpState, waterLevel + if (waterLevel): + pumpRelais.on() + pumpLed.on() + pumpState = True + Logger.LogMessage("Pump turned on") + else: + pumpRelais.off() + pumpLed.off() + pumpState = False + Logger.LogMessage("Could not turn on the pump - water level low!") + +# Turns off the watering pump +def WateringOff(value = ""): + global pumpState, wateringPulseState + pumpRelais.off() + pumpLed.off() + pumpState = False + wateringPulseState = False + Logger.LogMessage("Pump turned off") + +# Returns the state of the watering pump +def WateringState(): + global pumpState + if (pumpState): + return "on" + else: + return "off" + +# Reads the onboard temperature sensor's value +def ReadTemp(): + global temperature + temperature = 27 - ((tempSensor.read_u16() * (3.3 / (65535))) - 0.706) / 0.001721 + +# Turns on the automatic watering mode and writes file to set automode on reboot +def AutoModeOn(): + global autoState + if not "auto" in os.listdir(): + file = open("auto","w") + file.write("auto") + file.close() + autoLed.on() + autoState = True + Logger.LogMessage("Automatic mode turned on") + +# Turns off the automatic watering mode and removes file for automode on reboot +def AutoModeOff(): + global autoState + if "auto" in os.listdir(): + os.remove("auto") + autoLed.off() + autoState = False + Logger.LogMessage("Automatic mode turned off") + +# Returns the state of the automatic watering mode +def AutoModeState(): + global autoState + if (autoState): + return "on" + else: + return "off" + +# Turns on the watering pump for pulse_duration seconds and turns it off after that +def WateringPulse(): + global waterLevel, wateringPulseState, lastWateringPulse + if (waterLevel): + WateringOn() + lastWateringPulse = time.time() + wateringPulseState = True + timer3 = Timer(period=(1000 * configs["pulse_duration"]), mode=Timer.ONE_SHOT, callback=WateringOff) + Logger.LogMessage("Triggered watering pulse") + else: + Logger.LogMessage("Could not trigger watering pulse - Water level low!") + +# Reads the soil moisture sensors and triggers watering pulse if automatic mode is enabled +def ReadSoil(): + global soilDry, autoState, lastSoil1, lastSoil2, lastSoil3 + read1 = soilSensor1.read_u16() + read2 = soilSensor2.read_u16() + read3 = soilSensor3.read_u16() + Logger.LogSoil(str(read1) + "," + str(read2) + "," + str(read3) + ",") + lastSoil1 = read1 + lastSoil2 = read2 + lastSoil3 = read3 + # eliminate false readings + if (lastSoil1 > 25000): + lastSoil1 = 100 + if (lastSoil2 > 25000): + lastSoil2 = 100 + if (lastSoil3 > 25000): + lastSoil3 = 100 + if ((read1 + read2 + read3) / 3 <= configs["soil_dry_value"]): + soilDry = False + Logger.LogMessage("Soil is wet") + else: + soilDry = True + Logger.LogMessage("Soil is dry!") + if (autoState): + Logger.LogMessage("Triggering watering pulse due to automatic mode") + WateringPulse() + +# Returns if soil is dry or wet +def SoilState(): + global soilDry + if (soilDry): + return "dry" + else: + return "wet" + +# Returns the water level state +def WaterLevelState(): + global waterLevel, pumpState + if (waterLevel): + return "full" + else: + return "empty" + +# Reads the water level sensor +def ReadWaterLevel(): + global waterLevel + if (waterLevelSensor.value() == 0): + waterLevel = True + else: + waterLevel = False + if (pumpState): + Logger.LogMessage("Water level low - protecting the pump") + WateringOff() + +# This function makes sure, the watering pulse got turned off by the timer (can happen that it fails) +def CheckWateringPulseOff(): + global pumpState, lastWateringPulse + if (pumpState): + if (lastWateringPulse + 1 + configs["pulse_duration"] <= time.time()): + Logger.LogMessage("Turning watering pump off, because pulse timer failed to do so") + WateringOff() + +##################################################################### + +# Helper-method to allow error handling and output in asyncio +def set_global_exception(): + def handle_exception(loop, context): + Logger.LogMessage("Fatal error: " + str(context["exception"])) + import sys + sys.print_exception(context["exception"]) + sys.exit() + Reboot() + loop = asyncio.get_event_loop() + loop.set_exception_handler(handle_exception) + +# Main method for all the Watering-handling +async def WateringHandling(): + Logger.LogMessage("Watering handling started") + while True: + ReadTemp() + ReadWaterLevel() + CheckWateringPulseOff() + #todo any super cool action here + heartbeatLed.toggle() + await asyncio.sleep(0.5) + +# Main method for the API +html = """ + + Bewässerung +

Bewässerung

+

%s

+ +""" +json = """{ "Watering": { "%s" } }""" +async def APIHandling(reader, writer): + request_line = await reader.readline() + while await reader.readline() != b"\r\n": + pass + request = str(request_line) + try: + request = request.split()[1] + except IndexError: + pass + client_ip = writer.get_extra_info('peername')[0] + if request != "/favicon.ico": + Logger.LogMessage("API request: " + request + " - from client IP: " + client_ip) + if (configs['api_client_ip'] != "") and (configs['api_client_ip'] != client_ip): + Logger.LogMessage("Unauthorized client! Aborting API Handling now.") + stateis = "Error 401: Client '" + client_ip + "' is not authorized to use the API!

Set authorized client IP in configs.py!" + response = html % stateis + writer.write('HTTP/1.0 401 Unauthorized\r\nContent-type: text/html\r\n\r\n') + else: + req = request.split('/') + stateis = "" + if (len(req) == 3 or len(req) == 4 or len(req) == 5): + if (req[1] == secrets['api']): + if (req[2] == "wateron"): + Logger.LogMessage("Watering turned on") + stateis = "Watering turned: on" + WateringOn() + elif (req[2] == "wateroff"): + Logger.LogMessage("Watering turned off") + stateis = "Watering turned: off" + WateringOff() + elif (req[2] == "waterstate"): + Logger.LogMessage("Watering is turned " + WateringState()) + stateis = "Watering is turned: " + WateringState() + elif (req[2] == "waterpulse"): + Logger.LogMessage("Watering Pulse activated") + stateis = "Watering Pulse: activated" + WateringPulse() + elif (req[2] == "autoon"): + Logger.LogMessage("Automatic-Mode turned on") + stateis = "Automatic-Mode turned: on" + AutoModeOn() + elif (req[2] == "autooff"): + Logger.LogMessage("Automatic-Mode turned off") + stateis = "Automatic-Mode turned: off" + AutoModeOff() + elif (req[2] == "autostate"): + Logger.LogMessage("Automatic-Mode is turned " + AutoModeState()) + stateis = "Automatic-Mode is turned: " + AutoModeState() + elif (req[2] == "forcesoilread"): + ReadSoil() + Logger.LogMessage("Forced Soil Measurement Readings: " + str(lastSoil1) + ", " + str(lastSoil2) + ", " + str(lastSoil3)) + stateis = "Forced Soil Measurement Readings: now
soil1: " + str(lastSoil1) + "
soil2: " + str(lastSoil2) + "
soil3: " + str(lastSoil3) + elif (req[2] == "waterlevel"): + Logger.LogMessage("Water level is: " + WaterLevelState()) + stateis = "Water level is: " + WaterLevelState() + elif (req[2] == "ping"): + stateis = "ping: OK" + elif (req[2] == "stats"): + stateis = "IP address: " + Networking.GetIPAddress() + "
MAC address: " + Networking.GetMACAddress() + "
Hostname: " + configs['hostname'] + "
API Port: " + str(configs['api_port']) + "
Uptime (h:m): " + Uptime() + "
Date/Time: " + TimeUtils.DateTimeNow() + "
Version: " + version + "
GMT Timezone Offset (hours): " + str(configs['gmt_offset']) + "
Auto summertime: " + str(configs['auto_summertime']) + "
Housekeep logfiles after days: " + str(configs['log_housekeeping_days']) + "
CPU frequency (MHz): " + str(machine.freq()/1000000) + "
Temperature (°C): " + "%.2f" % temperature + "
Pulse duration (seconds): " + str(configs["pulse_duration"]) + "
Soil moisture measurement interval (minutes): " + str(configs["soil_moisture_measure_interval"]) + "
Soil is: " + SoilState() + "
Soil moisture 1: " + str(lastSoil1) + "
Soil moisture 2: " + str(lastSoil2) + "
Soil moisture 3: " + str(lastSoil3) + "
Automatic watering Mode: " + AutoModeState() + "
Water level status: " + WaterLevelState() + "
Check for updates (minutes): " + str(configs["check_for_update"]) + elif (req[2] == "reboot"): + stateis = "Rebooting device: now..." + Reboot() + elif (req[2] == "logs"): + if (len(req) >= 4 and req[3] != "json"): + if (IsInt(req[3])): + stateis = Logger.LastLogs(int(req[3])) + else: + stateis = "Error: Parameter for log length not an integer!" + else: + stateis = Logger.LastLogs(10) + elif (req[2] == "soils"): + if (len(req) >= 4 and req[3] != "json"): + if (IsInt(req[3])): + stateis = Logger.LastSoils(int(req[3])) + else: + stateis = "Error: Parameter for soil length not an integer!" + else: + stateis = Logger.LastSoils(6) + else: + stateis = "Error: Unknown command!" + else: + stateis = "Error: API key is invalid!" + if ((len(req) == 4 and req[3] == "json") or (len(req) == 5 and req[4] == "json")): + if (req[2] != "logs" and req[2] != "soils"): + stateis = stateis.replace(": ", "\":\"") + stateis = stateis.replace("
", "\", \"") + stateis = stateis.replace("°", "°") + else: + stateis = stateis.replace(";", "\":\"") + stateis = stateis.replace("
", "\", \"") + stateis = stateis.replace("\n", "").replace("\r", "") + response = json % stateis + writer.write('HTTP/1.0 200 OK\r\nContent-type: text/json\r\n\r\n') + else: + response = html % stateis + writer.write('HTTP/1.0 200 OK\r\nContent-type: text/html\r\n\r\n') + else: + stateis = "Error 400: Invalid usage of API!

Usage: http://servername/api_key/command[/json]

Commands:
API Key: set 'api' in secrets.py file." + response = html % stateis + writer.write('HTTP/1.0 400 Bad Request\r\nContent-type: text/html\r\n\r\n') + writer.write(response) + await writer.drain() + await writer.wait_closed() + +# Main method for daily housekeeping +async def Housekeeper(): + Logger.LogMessage("Housekeeper started") + while True: + Logger.LogMessage("Housekeeper is performing actions") + Logger.Housekeeping() + Logger.LogMessage("Housekeeper is performing NTP sync") + NTP.SetRTCTimeFromNTP(configs['ntp_host'], configs['gmt_offset'], configs['auto_summertime']) + Logger.LogMessage("Housekeeper has finished its jobs") + await asyncio.sleep(86400) + +# Main entry point after booting +async def Main(): + global autoState + set_global_exception() + Logger.LogMessage("Entering MainLoop") + boottime = time.time() + loop = asyncio.get_event_loop() + Logger.LogMessage("Setting up API on port " + str(configs['api_port']) + " with key " + secrets['api']) + loop.create_task(asyncio.start_server(APIHandling, Networking.GetIPAddress(), configs['api_port'])) + Logger.LogMessage("API started") + Logger.LogMessage("Booting complete with Firmware " + version) + ReadSoil() + timer = Timer(period=(1000 * 60 * configs["soil_moisture_measure_interval"]), mode=Timer.PERIODIC, callback=lambda t:ReadSoil()) + Logger.LogMessage("Started timer for soil moisture measurement every " + str(configs["soil_moisture_measure_interval"]) + " minutes") + if "auto" in os.listdir(): + autoState = True + autoLed.on() + Logger.LogMessage("Turned Automatic mode on from previous state") + loop.create_task(WateringHandling()) + loop.create_task(Housekeeper()) + loop.run_forever() + +# Booting the device +def Boot(): + Networking.Connect(configs['disable_wifi_powersavingmode']) + if (Networking.Status()): + wifiLed.on() + if (NTP.SetRTCTimeFromNTP(configs['ntp_host'], configs['gmt_offset'], configs['auto_summertime'])): + Logger.DisableTempLogfile() + Logger.DisableTempSoilfile() + if (ota_host != ""): + OTA.ota_update(ota_host, project_name, filenames, use_version_prefix=False, hard_reset_device=True, soft_reset_device=False, timeout=5) + timer2 = Timer(period=(1000 * 60 * configs["check_for_update"]), mode=Timer.PERIODIC, callback=lambda t:OTA.check_for_ota_update(ota_host, project_name, soft_reset_device=False, timeout=5)) + else: + wifiLed.off() + time.sleep(3) + Reboot() + +##################################################################### + +Boot() +try: + asyncio.run(Main()) +except KeyboardInterrupt: + Logger.LogMessage("Shutdown.") + pumpRelais.off() + pumpLed.off() + autoLed.off() + heartbeatLed.off() + wifiLed.off() +finally: + asyncio.new_event_loop() \ No newline at end of file diff --git a/secrets.py b/secrets.py new file mode 100644 index 0000000..2f063b0 --- /dev/null +++ b/secrets.py @@ -0,0 +1,5 @@ +secrets = { + 'ssid': 'wifiname', + 'pw': 'wifipassword', + 'api': 'test', + } \ No newline at end of file