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