diff --git a/autoexec.be b/autoexec.be new file mode 100644 index 0000000..f7f4e8c --- /dev/null +++ b/autoexec.be @@ -0,0 +1,29 @@ + +var name = nil # will use tasmota device name unless string given here + +debug = [false,false,false,false] + +# use in development with the utils.tapp bundle +# import sys +# sys.path().push('utils.tapp#') + +import weather +var w + +def run() + tasmota.remove_rule('Wifi#Connected', 2) + print('wifi now connected, staring weather app', tasmota.wifi()) + w=weather.create({'name': name}) +end + +if tasmota.wifi().find('mac') + print('wifi already connected, starting app') + w=weather.create({'name': name}) +else + print('waiting for wifi to connect') + tasmota.add_rule('Wifi#Connected' , run) +end + +def _r () + tasmota.cmd('restart 99') +end diff --git a/bundle b/bundle new file mode 100755 index 0000000..51ec99c --- /dev/null +++ b/bundle @@ -0,0 +1,14 @@ +#!/bin/bash +# todo set utility directory and also customize start.be +MODULES=/data/coding/berry/modules + +rm ./bin/weather.tapp +zip -j -Z store ./bin/weather.tapp \ +autoexec.be \ +weather.be \ +$MODULES/util/stringe.be \ +$MODULES/util/file.be \ +$MODULES/util/object.be \ +$MODULES/util/time.be \ +$MODULES/util/ha.be + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..5b9022b --- /dev/null +++ b/readme.md @@ -0,0 +1,6 @@ +run ./bundle to + +Typical eps32 pinouts +GPIO18 Counter_n 2 Rain Gauge +GPIO19 Counter_n 1 Annemometer +GPIO34 ADC 1 Direction \ No newline at end of file diff --git a/weather.be b/weather.be new file mode 100644 index 0000000..09401ef --- /dev/null +++ b/weather.be @@ -0,0 +1,424 @@ +import introspect +import json +import math +import string +import mqtt +import path # command if running on dev machine +# uncomment NOT running on tasmota device +# import tasmota +# import os +# var path +# path=os.path + +# UCI Utility Modules +# in development set sys.path to find these modules +import stringe +import time +# manipulates and loads/saves object to file +import object +# home assistant integration +import ha + + + + +def getfilename (file,opts) + if isinstance(file, map) + opts = file + file = nil + end + opts = opts ? opts : {} + var name = opts.find('name') + var Device = tasmota.cmd('status')['Status']['DeviceName'] + var device = string.tolower(string.split(Device,' ').concat('_')) + file = file ? file : (name ? name : device) + file = stringe.nospaces(stringe.tolower(file)) + file = string.find(file,'.') < 0 ? file+'.cfg' : file + return file +end + + +var configure = def (file,opts) + + var name + if isinstance(file, map) + opts = file + file = nil + end + file = getfilename(file,opts) + opts = opts ? opts : {} + name = opts.find('name') + if !name name = tasmota.cmd('status')['Status']['DeviceName'] end + if path.exists(file) + print('existing configuration file', file,' use `force`:true in the options to overwrite') + if ! opts.find('force') return end + end + opts.remove('force') + print('file name opts') + print(file,name,opts) + var p = object(opts,file) + p.name = name + p.id = string.tolower(string.split(p.name,' ').concat('_')) + p.speed = number(p.speed ? p.speed : 1) # counter for annemometer + p.speedunit = p.speedunit ? p.speedunit : "MPH" + p.speeddebounce = p.speeddebounce ? p.speeddebounce : 10 # milliseconds + p.speedcal = p.speedcal ? p.speedcal : p.speedunit == "MPH" ? 1.492 : 2.4 + p.speeddur= number(p.speeddur ? p.speeddur : 5 ) + p.speedtopic = p.speedtopic ? p.speedtopic : 'weather/' + p.id +'/wind_speed' + p.direction = number(p.direction ? p.direction : 1) # default ADC A1 + p.direction_file = p.direction_file ? p.direction_file : p.id+'-direction.json' + p.direction_error = number(p.direction_error ? p.direction_error : 25) + p.directiontopic = p.directiontopic ? p.directiontopic : 'weather/'+ p.id + '/wind_direction' + p.rain = number(p.rain ? p.rain : 2) # assume adc1 + p.raindebounce = p.raindebounce ? p.raindebounce : 10 # milliseconds + p.rainunit = p.rainunit ? p.rainunit : "in" + p.raincal = p.raincal ? p.raincal : p.rainunit == "in" ? 0.011 : 0.274 + p.raintopic = p.raintopic ? p.raintopic : 'weather/'+ p.id + '/rain' + p.tempunit = p.tempunit ? p.tempunit : "F" + print('WEATHER STATION CONFIGURATION') + print(p.get()) + p.save() + print("saved configuration to: ",p.file.name()) + return p +end + +# configuration settings as initially set by separate configure function +# name # name for weather station instance +# id # derived from name +# sensor # temperature sensor name as used in telemetry +# speed # tasmota number of annemometer switch (1 by default) +# direction # tasmota configured number for ADC +# temp/humid/press sensor name + +class Weather + + # private states + var _temp # last temperature from the sensor + var _time # time of last temperature reading + var _acount # cumulative count for 1 minute average + var _rain # cumulative rain for 24 hours as array with 0 current day + var _atime # cumulative time for next 1 minute average + var scounter # string for counter with number for commands + var _wdtbl # wind lookup table + var _mqtt # state of connection to broker + var cfg # weather station configuration + var data # persist live data to file + + static appname = 'weather' + + def init (file,opts) + # TODO if opts has update:true, then other opts will update/add to existing configuration + ## CONFIGURE ############################# + if isinstance(file, map) + opts = file + file = nil + end + + file = getfilename(file,opts) + if path.exists(file) + self.cfg = object(file) + if self.cfg == nil + print ("ERROR: loading configuration file", file) + return + else + print('loaded configuration file',file,'for',self.cfg.name) + print('configuration', self.cfg.get()) + end + else + print('WARNING: no configuration file exists, attemping to make make one') + self.cfg = configure(file,opts) + if !self.cfg + print('unable to make configurate file, aborting') + return + else + print('created config file',file,' continuting to initialize') + end + end # CONFIGURE ############################### + + # wait for mqtt broker connect + # TODO make this part of an mqtt module using mqtt functions in this module + self.mqtt_wait(/ -> self.ha_create_entities()) + + # DIRECTION: ################ + + if path.exists(self.cfg.direction_file) + var f + try + f = open(self.cfg.direction_file, "r") + self._wdtbl = json.load(f.read()) + f.close() + except .. as e, m + if f != nil f.close() end + raise e, m + end + print('wind-direction table loaded, listening for new direction') + tasmota.add_rule("ANALOG#A"+str(self.cfg.direction) , / value -> self.process_direction(value)) + if debug[3] print(self._wdtbl) end + else + print('FATAL: missing wind direction',self.cfg.direction_file, ' direction detection disabled') + end + # END DIRECTION ################ + + ############ Temperature: ################ + tasmota.cmd("SetOption8 "+str(self.cfg.tempunit == "F" ? "1" : "0")) + var status = tasmota.cmd("status 10")['StatusSNS'] + if debug[3] print(status) end + if self.cfg.tempunit != status['TempUnit'] + print ('FATAL ERROR: unable to set system temperature unit to ',self.cfg.tempunit) + end + # TODO need config and rules for temperature/humid/press/sensor if enabled + # END TEMPERATURE + + # RAIN: ########################## + ######## for using switch intead of counter ########## + # tasmota.cmd("SetOption114 1") + # tasmota.cmd("SwitchMode"+str(self.cfg.rain)+" 13") + # tasmota.add_rule("Switch"+str(self.cfg.rain)+"#ACTION", / value -> self.process_rain(value)) + ####################################################### + + tasmota.cmd('Counter'+str(self.cfg.rain)+' 0') # reset counter + tasmota.cmd('CounterDebounce'+str(self.cfg.rain)+' '+str(self.cfg.raindebounce)) + tasmota.add_rule("Counter#C"+str(self.cfg.rain), / value -> self.rain_event(value)) + # load or set up rain datafile + # TODO combined data file with rain data as a key, requires object module refactor + var datafile = self.cfg.id+'_rain.dat' + self._rain = object(datafile,{'autoload':false}) + if path.exists(datafile) + self._rain.load() + else + # TODO make current key with data underneath for current unsaved hour + self._rain.unit = self.cfg.rainunit + self._rain.today = 0 + self._rain.current_hour = 0 + self._rain.daily = {} + self._rain.last_rain_time = '' + self._rain.last_rain_etime = 0 + self._rain.hourly = [] + self._rain.hourly.resize(24) + self._rain.save() + end + self._rain._autosave = true + self._rain.status = 'init' + if debug[1] print('initial rain data', self._rain.get()) end + # TODO - do smarter recovery of current hour, and today totals. + self._rain.current_hour = 0 + self._rain.today = 0 + + tasmota.add_cron("0 0 * * * *", /-> self.rain_hour(), "rain-hour") + ######### END RAIN ############ + + ######## SPEED: annemometer ##################### + self._acount = 0 + self._atime = 0 + tasmota.cmd('CounterDebounce'+str(self.cfg.speed)+' '+str(self.cfg.speeddebounce)) + tasmota.cmd('CounterDebounceLow'+str(self.cfg.speed)+' 0' ) + tasmota.cmd('CounterDebounceHigh'+str(self.cfg.speed)+' 0' ) + if debug[0] print('speed debounce:',tasmota.cmd('CounterDebounce'+str(self.cfg.speed))) end + self.scounter = 'Counter'+str(self.cfg.speed) + self.start_speed_timer() + # END SPEED + + end # end init + + ################ WIND SPEED PROCESSING #################### + + # TODO allow settable timeout for speed calculation (default 5) + def start_speed_timer() + tasmota.cmd(self.scounter + ' 0') # reset counter + tasmota.set_timer(5000,/-> self.process_speed()) + end + + def process_speed () + # TODO save all time max, save daily max, daily average, use cron + def speed_calc(count,time) + if !time time = 5 end # TODO will use cfg.speedtime + return string.format('%.1f',count * self.cfg.speedcal / time) + end + var count=tasmota.cmd(self.scounter)[self.scounter] + self.start_speed_timer() + if self._atime >= 60 + var aspeed = speed_calc(self._acount,60) + if debug[2] print ('AVERAGE SPEED: count',self._acount,'speed', aspeed) end + if self._mqtt + mqtt.publish('weather/'+self.cfg.id+'/avespeed', aspeed) + else + print('WARNING: unable to publish speed, not connected to broker') + end + self._acount = 0 + self._atime = 0 + else + self._acount += count + self._atime += 5 + end # average time + var speed = speed_calc(count) # default is 5 + if debug[0] && count > 0 print ('CURRENT SPEED: count', count, 'speed', speed, 'count for average', self._acount) end + if self._mqtt + mqtt.publish(self.cfg.speedtopic, speed) + else + print('WARNING: unable to publish speed, not connected to broker') + end + end + +#*********************************************************** + + def wdlookup(val) + var raw + if debug[2] print('looking up',val,'in table with error margin +-',self.cfg.direction_error) end + for e:self._wdtbl + raw = e.item('RAW') + if debug[0] print(val,raw,math.abs(raw - val), self.cfg.direction_error) end + # if math.abs(raw - val) < raw * number(self.cfg.direction_error)/100 + if math.abs(raw - val) < self.cfg.direction_error + if debug[2] print('matched a direction=>',e) end + return e + end + end + if debug[0] print('not able to match a direction for analog value', val) end + return nil + end + + def process_direction(val) + + var res + res = self.wdlookup(val) + if res + if debug[0] print('Direction:',res.item('Direction')) end + mqtt.publish(self.cfg.directiontopic, json.dump(res)) + end + end + + def rain_hour() + var hour = number(time.prop('hour')) + if debug[3] hour = 0 end + if hour == 0 # new day + if debug [3] print(time.today('ymd'),': new day saving last days rain day ##########') end + self._rain.daily.setitem(time.yesterday('ymd'),{ 'total': self._rain.today, 'hourly': self._rain.hourly }) + self._rain.today = 0 + self._rain.hourly.clear() + self._rain.hourly.resize(24) # inflate hourly back to 24 + else + if debug[3] print('saving last hours rain amount', hour, self._rain.current_hour) end + self._rain.hourly[hour - 1]=self._rain.current_hour + end + self._rain.current_hour = 0 + mqtt.publish(self.cfg.raintopic, self._rain.dump()) + end + + def rain_event (value) + if value > 0 && self._rain.status == 'active' + var hour = number(time.prop('hour')) + self._rain.today += self.cfg.raincal + self._rain.current_hour += self.cfg.raincal + self._rain.hourly[hour]=self._rain.current_hour + self._rain.last_rain_time = time.now() + self._rain.last_rain_etime = time.now('e') + if debug[3] + print(time.tod(),'rain event on:',time.day('dmy'),'todays, last hour',self._rain.today,self._rain.current_hour) + print(self._rain.hourly) + end + mqtt.publish(self.cfg.raintopic, self._rain.dump()) + end + self._rain.status = 'active' # after first call rain gauge is active + tasmota.cmd('Counter'+str(self.cfg.rain)+' 0') # reset counter + end + + def config (ret) + if ret == 'print' + print('*****************************') + print('----------Settings ----------') + print('weather station name: '+ str(self.cfg.name)) + print('weather id: '+ str(self.cfg.id)) + print('annemometer switch number: '+ str(self.cfg.speed)) + print('wind direction ADC channel number: '+ str(self.cfg.direction)) + print('wind acceptable error from table value: '+ str(self.cfg.direction_error)) + print('rain gauge switch number number: '+ str(self.cfg.rain)) + print('******************************') + return + end + if ! ret return self.cfg.get() end + if ret == 'json' return json.dump(self.cfg.get()) end + end + + # TODO create a data state for publishing + # def state(format) + # end + + # publish current state to mqtt + # def publish(format) + # end + + + def ha_create_entities() + print('creating HA entities') + ha.entity_create('Wind Direction', { + 'jsonkey':'Direction', + 'icon':'mdi:compass-rose', + 'topic': self.cfg.directiontopic + }) + + ha.entity_create('Wind Speed', { + 'icon':'mdi:weather-windy', + 'topic': self.cfg.speedtopic, + 'unit' : self.cfg.speedunit + }) + + + ha.entity_create('Todays Rain', { + 'icon':'mdi:weather-pouring', + 'jsonkey':'today', + 'topic': self.cfg.raintopic, + 'unit' : self.cfg.rainunit + }) + # TODO create entities for other rain data + end + + # TODO pass a handler function to call + def mqtt_wait(con,dis) + if dis dis() end + self._mqtt = false + tasmota.remove_rule("mqtt#disconnected",2) + tasmota.add_rule("mqtt#connected", /-> self.mqtt_connected(con),2) + end + + # TODO pass a handler function to call + def mqtt_connected(con) + self._mqtt=true + if con con() end + tasmota.remove_rule("mqtt#connected",2) + tasmota.add_rule("mqtt#disconnected", /-> self.mqtt_wait(),2) + end + + def mqtt_connected_cmds() + print("subscribing to weather station commands via mqtt") + tasmota.cmd("Subscribe "+self.cfg.id,"/weather/set/"+self.cfg.id) + tasmota.add_rule("Event#weather"+self.cfg.id, /-> self.mqtt_process_cmd()) + end + + def mqtt_disconnected_cmds() + tasmota.remove_rule("Event#weather"+self.cfg.id, 2) + end + + def mqtt_process_cmd(payload) + print("incoming command payload (as json) from mqtt") + print(payload) + var _p = json.load(payload) + _p = _p ? _p : {} + if _p.contains('set') && _p.contains('value') + var prop = _p.item('set') + introspect.set(self,prop,_p.item('value')) + print('new value for ',prop,' is ',introspect.get(self,prop)) + end + if _p.contains('method') + var f = introspect.get(self,_p.item('method')) + call(f,self,_p.item('args')) + end + end + +end # end of Weather Class + + +var weather = module('weather') +weather.create = Weather +weather.configure = configure + +return weather diff --git a/wind-direction.json b/wind-direction.json new file mode 100644 index 0000000..2ad395c --- /dev/null +++ b/wind-direction.json @@ -0,0 +1,98 @@ +[ + { + "RAW": 3014, + "CW": 0, + "CCW": 360, + "Direction": "N" + }, + { + "RAW": 1483, + "CW": 22.5, + "CCW": 337.5, + "Direction": "NNE" + }, + { + "RAW": 1692, + "CW": 45, + "CCW": 315, + "Direction": "NE" + }, + { + "RAW": 163, + "CW": 67.5, + "CCW": 292.5, + "Direction": "ENE" + }, + { + "RAW": 200, + "CW": 90, + "CCW": 270, + "Direction": "E" + }, + { + "RAW": 90, + "CW": 112.5, + "CCW": 247.5, + "Direction": "ESE" + }, + { + "RAW": 566, + "CW": 135, + "CCW": 225, + "Direction": "SE" + }, + { + "RAW": 332, + "CW": 157.5, + "CCW": 202.5, + "Direction": "SSE" + }, + { + "RAW": 980, + "CW": 180, + "CCW": 180, + "Direction": "S" + }, + { + "RAW": 806, + "CW": 202.5, + "CCW": 157.5, + "Direction": "SSW" + }, + { + "RAW": 2369, + "CW": 225, + "CCW": 135, + "Direction": "SW" + }, + { + "RAW": 2368, + "CW": 247.5, + "CCW": 112.5, + "Direction": "WSW" + }, + { + "RAW": 3927, + "CW": 270, + "CCW": 90, + "Direction": "W" + }, + { + "RAW": 3216, + "CW": 292.5, + "CCW": 67.5, + "Direction": "WNW" + }, + { + "RAW": 3530, + "CW": 315, + "CCW": 45, + "Direction": "NW" + }, + { + "RAW": 2675, + "CW": 337.5, + "CCW": 22.5, + "Direction": "NNW" + } +] \ No newline at end of file