.
[purpleair-notify.git] / script.rb
blobe8d598af620b43fa4136b6df2177a913db01e97e
1 #!/usr/bin/env ruby --jit
3 =begin
4 Copyright 2019 Wu Ming 2 at wu.ming2@icloud.com
6 This program is free software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, either version 3 of the License, or
9 (at your option) any later version.
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 GNU General Public License for more details.
16 You should have received a copy of the GNU General Public License
17 along with this program.  If not, see <https://www.gnu.org/licenses/>.
18 =end
20 require 'net/http'
21 require 'json'
22 require 'discordrb'
23 require 'hashdiff'
24 require 'redis'
25 require 'time'
26 require 'date'
28 class Config
30         # Threshold
31         MY_THRESHOLD = 10.0             # PM2.5 µg/m³
33         # Discord
34         DISCORD_CHANNEL_ID = <Channel ID>
35         DISCORD_AUTHOR_ID = <Your ID>   # just one author for now
36         DISCORD_TOKEN = '<Token>'
37         DISCORD_HISTORY_SIZE = 10  # increase if more chatty
39         PA_URI = URI('https://www.purpleair.com/json?show=<Your PA ID>')
41         # Implement Observatory module after updating this
42         OB_URI = URI('<Alternate source of T and Humidity source URL>')
43         LOCATION = "<Alternate source of T and Humidity source display name>"
45         # Timing
46         TIME = Time.now
47         # 30' heartbeat
48         TIME_10_FLAG = TIME.min.between?(01,19)
49         TIME_40_FLAG = TIME.min.between?(31,49)
50         TIME_BEGIN_PREV = TIME-30*60
51         # night is off
52         NIGHTTIME_FLAG = TIME.hour.between?(00,05)
53         # night period boundaries
54         EXP_REF = NIGHTTIME_FLAG ? 5 : 0 
55         DAILY_FLAG = TIME.hour == 8
56         WEEKLY_FLAG = DAILY_FLAG && TIME.sunday?
58         # Cache
59         MESSAGING = {"!start" => false, "!stop" => true}
61         # General exlusion period
62         exit(0) if Config::TIME_10_FLAG || Config::TIME_40_FLAG
63 end
65 module Proceed
66         # Keeps quiet if user wishes so or at night time
67         MessagesNotAllowedNow = proc do |cached_wish|
68                 cached_wish ||= CacheControl::GetWish.call
69                 # true means !stop
70                 # user wish always prevails if not nil
71                 # dynamic flag before | after Discord update
72                 [ Config::MESSAGING[cached_wish], Config::NIGHTTIME_FLAG ].compact.first
73         end
74         IsAboveThreshold = proc {Purple::Current > Config::MY_THRESHOLD}
75         WasBelowThreshold = proc {Purple::Hour_Avg <= Config::MY_THRESHOLD}
76         BelowThresholdAgain =
77                 # Avoids messaging if notified already below threshold
78                 proc {!IsAboveThreshold.call && WasBelowThreshold.call}
79         IsHumidityOutOfRange = proc {!Observatory::Humidity.to_i.between?(30,70)}
80 end
82 module NeedToKnow
83         result = JSON.parse(File.read('purple_dump.json'))["results"][0] # Hash
84         res_a_keys = result.keys        # Array
85         stats_a_keys = JSON.parse(result["Stats"]).keys # Array
86         check_diff = proc do |keys1, keys2|
87                 Hashdiff.diff(keys1,keys2)
88         end
90         # Just to try currying
91         Check_res = check_diff.curry.call(res_a_keys)
92         Check_stats = check_diff.curry.call(stats_a_keys)
93 end
95 module Conn
96         #Bot = Discordrb::Bot.new(token: Config::DISCORD_TOKEN, ignore_bots: true)
97         #Channel = Bot.channel(Config::DISCORD_CHANNEL_ID)
98         PurpleData = Net::HTTP.get(Config::PA_URI)
99         Red = Redis.new
102 module BotControl
103         # Discordrb::Bot is not intended to be used without bot.run
104         #Start = proc {Conn::Bot.run(:async)} # returns Proc instead of executing at assignment 
105         #Stop = proc {Conn::Bot.stop; exit(0)}  # returns an ERROR message
106         #Send = proc {|msg| Conn::Bot.send_message(Conn::Channel, msg)}
107         #Quit = proc {|msg| (Stop<<Send).call(msg)}
108         response = Discordrb::API::Channel.messages(    # gets last messages
109                 "Bot "+Config::DISCORD_TOKEN, Config::DISCORD_CHANNEL_ID, 
110                 Config::DISCORD_HISTORY_SIZE )  # newest first
111         parse = proc { |resp| JSON.parse(resp) } # RestClient::Response -> [(0..n)Hash]
112         by_time = proc { |a|    #  [(0..n)Hash] -> [(0..n)Hash]
113                 a.select { |m| 
114                         Time.parse(m["timestamp"]).getlocal >= Config::TIME_BEGIN_PREV } }
115         by_author = proc { |a|  #  [(0..n)Hash] -> [(0..n)Hash] 
116                 a.select{ |m|
117                         m["author"]["id"] == Config::DISCORD_AUTHOR_ID.to_s } }
118         by_content = proc { |a|         # [(0..n)Hash] -> nil or Str
119                 Config::MESSAGING.keys.find { |k| 
120                         a.any? { |m| m["content"].start_with?(k) } } }
122         # public
123         Send = proc {|msg| Discordrb::API::Channel.create_message(
124                 "Bot "+Config::DISCORD_TOKEN, Config::DISCORD_CHANNEL_ID, msg )}
125         Wish = (by_content<<by_author<<by_time<<parse).call(response)
128 module CacheControl
129         getFlag = proc {|flag| Conn::Red.get(flag)} # -> nil or Str
130         
131         expSec = (
132                 t = Time.now
133                 # from midnight of tomorrow or
134                 # from REF of today
135                 d = Config::EXP_REF == 0 ? Date.today + 1 : Date.today
136                 # return expiration time in sec from REF
137                 ( Time.new(d.year,d.month,d.day,Config::EXP_REF,0,0) - t ).to_i
138         )
139         # User wish for start/stop
140         GetWish = proc {getFlag.call("messaging")} # changes w update
141         setWish = proc do |wish,cached_wish|
142                 next unless wish
143                 # valid user wish is always opposite of time-based flag
144                 # if cache doesn't exist and is valid create cache
145                 # if cache exist is opposite already so delete
146                 cached_wish.nil? ? 
147                         Conn::Red.set("messaging", wish, :ex => expSec) :
148                         Conn::Red.del("messaging")
149         end
150         userWish = proc do |user_wish|
151                 # see .gv file for logic
152                 # More clever solution for binary decision tree surely exist.
153                 next unless user_wish   # no news from user, returns nil
154                 cached_wish = GetWish.call
155                 # cached flag or time-based flag
156                 sys_wish = Proceed::MessagesNotAllowedNow.call(cached_wish)
157                 next if sys_wish == Config::MESSAGING[user_wish] # if old or silly news
158                 BotControl::Send.call("#{user_wish} got it! Until next period change.")
159                 [user_wish,cached_wish]
160                 # returns nil or [user,nil] or [user,cached]
161         end
162         autoUpdate = (setWish<<userWish).call(BotControl::Wish)
165 module Purple
166         results = JSON.parse(Conn::PurpleData)["results"] # [(2)]
167         stats_a = JSON.parse(results[0]["Stats"])       # Hash
168         stats_b = JSON.parse(results[1]["Stats"])       # Hash
169         value = proc {|key| (stats_a[key].to_f + stats_b[key].to_f) / 2}
170         
171         # check if data format changed
172         Res_diff = NeedToKnow::Check_res.call(results[0].keys)
173         Stats_diff = NeedToKnow::Check_stats.call(stats_a.keys)
174         
175         # public
176         Age = results[0]["AGE"]
177         Current = value.call("v1")
178         Hour_Avg = value.call("v3")
179         Day_Avg = value.call("v5")
180         Week_Avg = value.call("v6")
181         #Failure = A_G not available anymore??
184 module Observatory
185         # low resilience to page formatting changes
186         Temperature, Humidity = Net::HTTP.get(Config::OB_URI).lines.<implement>
189 module Messages
190         msg = Proceed::IsAboveThreshold.call ? "START" : "OK"
191         humidity = Proceed::IsHumidityOutOfRange.call ? "Humidity needs active regulation!!" : ""
192         particulate = "Average: last ten min #{Purple::Current.to_i.to_s}."
193         temperature = "Temperature #{Observatory::Temperature.to_s}°C in #{Config::LOCATION}."
194         daily = Config::DAILY_FLAG ? "Average: last day #{Purple::Day_Avg.to_i.to_s}." : ""
195         weekly = Config::WEEKLY_FLAG ? "Average: last week #{Purple::Week_Avg.to_i.to_s}." : ""
196         res_diff = Purple::Res_diff.empty? ? "" : "Channel changed: #{Purple::Res_diff}"
197         stats_diff = Purple::Stats_diff.empty? ? "" : "Stats changed: #{Purple::Stats_diff}"
198         
199         # public
200         Message = (
201                 msg + " " + particulate + " " + humidity + " " +
202                 temperature + " " + daily + " " + weekly + " " +
203                 res_diff + " " + stats_diff
204         )
205         Age = Purple::Age > 10 ? "Data #{Purple::Age} minutes old." : "" 
206         #Failure = "Possible hardware issue: #{@readings[:failure]}" if Purple::Failure??