initial commit of weather station code.

operation with home assistant entity creation
master
Kebler Network System Administrator 2022-09-02 17:18:07 -07:00
parent 23eba73bd8
commit 15e394f490
5 changed files with 571 additions and 0 deletions

29
autoexec.be Normal file
View File

@ -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

14
bundle Executable file
View File

@ -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

6
readme.md Normal file
View File

@ -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

424
weather.be Normal file
View File

@ -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

98
wind-direction.json Normal file
View File

@ -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"
}
]