temps.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. #!/usr/bin/python
  2. __author__ = "Kruz, Wazakindjes"
  3. __copyright__ = "Jem0eder Inc."
  4. __version__ = "2.0.0"
  5. import argparse
  6. import ConfigParser
  7. import datetime
  8. import json
  9. import MySQLdb
  10. from onesignal import OneSignal
  11. import os
  12. import requests
  13. import sys
  14. import time
  15. from var_dump import var_dump
  16. # Some "constants" lol
  17. RET_GUCCI = 0
  18. RET_ERR_CONF = 1
  19. RET_ERR_SENSOR = 2
  20. RET_ERR_WEATHERMON = 3
  21. # Globals y0
  22. muhconf = {}
  23. skripdir = os.path.dirname(os.path.realpath(__file__))
  24. def printem(obj):
  25. # Because apparently we need to flush after every print to ensure it happens in real-time -.-
  26. # Also this waii we can check if the object is properly printable by default, some objects may not be so let's use var_dump() for that shit instead ;]
  27. if isinstance(obj, (float, int, str, list, dict, tuple)):
  28. print obj
  29. else:
  30. var_dump(obj)
  31. sys.stdout.flush()
  32. def try_db_cleanup(db, cursor):
  33. # Some objects may not exist and perhaps db does but not cursor, hence 2 different try blocks ;]
  34. # It shouldn't really be a problem if we quit before closing the connection, but it *is* good practice to close em cleanly
  35. try:
  36. cursor.close()
  37. except:
  38. pass
  39. try:
  40. db.close()
  41. except:
  42. pass
  43. def show_version():
  44. printem("temps.py v{0}, written by: {1}".format(__version__, __author__))
  45. def readem_conf():
  46. global muhconf
  47. err = False
  48. muhconf = {
  49. "main": {},
  50. "mysql": {},
  51. "weathermon": {},
  52. }
  53. try:
  54. cfg = ConfigParser.SafeConfigParser({
  55. # Some variables are optional ;]
  56. # [main] section
  57. "debug": "false",
  58. "dryrun": "false",
  59. "sample_maxattempts": "5",
  60. # [mysql] section
  61. "port": "3306",
  62. "tls": "false",
  63. # [weathermon] section
  64. "enable": "false",
  65. "tls_ca_file": "/etc/ssl/certs/ca-certificates.crt",
  66. "onesignal_segment": None,
  67. # The other variables (interval, url, key) are not optional if enable == true, so they shouldn't be specified here either ;]
  68. })
  69. cfg.read("{0}/muhconf.ini".format(skripdir))
  70. ### [main] section BEGIN
  71. # Booleans first
  72. for main_boolopt in ["debug", "dryrun"]:
  73. muhconf["main"][main_boolopt] = cfg.getboolean("main", main_boolopt)
  74. # Integers next
  75. for main_intopt in ["sample_maxattempts"]:
  76. optval = cfg.getint("main", main_intopt)
  77. # Should prolly restrict it to 30 attempts, anything more seems way too excessive [==[[==[[=
  78. if main_intopt == "sample_maxattempts":
  79. if optval <= 0 or optval > 30:
  80. printem("Invalid value '{0}' for option '{1}': must be in the range of 1-30".format(optval, main_intopt))
  81. err = True
  82. continue
  83. muhconf["main"][main_intopt] = optval
  84. # Multiple string values last
  85. for main_stropt_multi in ["sensor"]:
  86. optval_multi = cfg.get("main", main_stropt_multi).split("\n")
  87. for optval in optval_multi:
  88. if len(optval) == 0:
  89. printem("Invalid value for option '{0}': may not be left empty".format(main_stropt_multi))
  90. err = True
  91. continue
  92. if main_stropt_multi == "sensor":
  93. if "/" in optval:
  94. printem("Invalid value '{0}' for option '{1}': may not contain a slash".format(optval, main_stropt_multi))
  95. err = True
  96. continue
  97. if not os.access("/sys/bus/w1/devices/{0}/w1_slave".format(optval), os.R_OK):
  98. printem("Invalid value '{0}' for option '{1}': file '/sys/bus/w1/devices/{0}/w1_slave' doesn't exist or is not readable".format(optval, main_stropt_multi))
  99. err = True
  100. continue
  101. muhconf["main"][main_stropt_multi] = optval_multi
  102. ### [main] section END
  103. ### [mysql] section BEGIN
  104. # Booleans first
  105. for mysql_boolopt in ["tls"]:
  106. muhconf["mysql"][mysql_boolopt] = cfg.getboolean("mysql", mysql_boolopt)
  107. # Integers next
  108. for mysql_intopt in ["port"]:
  109. optval = cfg.getint("mysql", mysql_intopt)
  110. if mysql_intopt == "port":
  111. if optval <= 0 or optval > 65535:
  112. printem("Invalid value '{0}' for option '{1}': must be in the range of 1-65535".format(optval, mysql_intopt))
  113. err = True
  114. continue
  115. muhconf["mysql"][mysql_intopt] = optval
  116. # String values last
  117. for mysql_stropt in ["host", "user", "pass", "db", "table", "column"]:
  118. optval = cfg.get("mysql", mysql_stropt)
  119. if len(optval) == 0:
  120. printem("Invalid value for option '{0}': may not be left empty".format(mysql_stropt))
  121. err = True
  122. continue
  123. muhconf["mysql"][mysql_stropt] = optval
  124. ### [mysql] section END
  125. ### [weathermon] section BEGIN
  126. # Booleans first
  127. for weathermon_boolopt in ["enable"]:
  128. muhconf["weathermon"][weathermon_boolopt] = cfg.getboolean("weathermon", weathermon_boolopt)
  129. if muhconf["weathermon"]["enable"]:
  130. # Integers next, but only if shit's enabled lol (obviously)
  131. for weathermon_intopt in ["interval", "city_id"]:
  132. optval = cfg.getint("weathermon", weathermon_intopt)
  133. # Free weather APIs generally don't update faster than once every 10 minutes, but you should checkem at least once per hour
  134. if weathermon_intopt == "interval":
  135. if optval < 10 or optval > 60:
  136. printem("Invalid value '{0}' for option '{1}': must be in the range of 10-60".format(optval, weathermon_intopt))
  137. err = True
  138. continue
  139. elif weathermon_intopt == "city_id":
  140. if optval <= 0:
  141. printem("Invalid value '{0}' for option '{1}': must be greater than 0".format(optval, weathermon_intopt))
  142. err = True
  143. continue
  144. muhconf["weathermon"][weathermon_intopt] = optval
  145. # String values last
  146. for weathermon_stropt in ["weather_url", "weather_key", "tls_ca_file", "onesignal_restkey", "onesignal_appid", "onesignal_channel", "onesignal_segment"]:
  147. optval = cfg.get("weathermon", weathermon_stropt)
  148. # Cuz certain optional vars default to None lol (missing keys throw an exception ;])
  149. if optval is None:
  150. muhconf["weathermon"][weathermon_stropt] = optval
  151. continue
  152. if len(optval) == 0:
  153. printem("Invalid value for option '{0}': may not be left empty".format(weathermon_stropt))
  154. err = True
  155. continue
  156. elif weathermon_stropt in ["weather_url"]:
  157. if not optval.startswith("https://"):
  158. printem("Invalid value '{0}' for option '{1}': must start with https://".format(optval, weathermon_stropt))
  159. err = True
  160. continue
  161. elif weathermon_stropt == "tls_ca_file":
  162. if not optval.startswith("/"):
  163. printem("Invalid value '{0}' for option '{1}': must start with a slash (absolute path y0)".format(optval, weathermon_stropt))
  164. err = True
  165. continue
  166. if not os.access(optval, os.R_OK):
  167. printem("Invalid value '{0}' for option '{1}': file doesn't exist or is not readable".format(optval, weathermon_stropt))
  168. err = True
  169. continue
  170. muhconf["weathermon"][weathermon_stropt] = optval
  171. ### [weathermon] section END
  172. except KeyboardInterrupt:
  173. printem("\nCTRL + C")
  174. sys.exit(RET_GUCCI)
  175. except:
  176. exc_info = sys.exc_info()
  177. printem("\nRIP config ({0}): {1}".format(exc_info[0], exc_info[1]))
  178. sys.exit(RET_ERR_CONF)
  179. if err:
  180. printem("Invalid config, not proceeding")
  181. sys.exit(RET_ERR_CONF)
  182. def readem_temp():
  183. avgtemperatures = []
  184. db = None
  185. cursor = None
  186. try:
  187. if muhconf["main"]["debug"]:
  188. printem("Connecting to MySQL lol")
  189. if muhconf["mysql"]["tls"]:
  190. db = MySQLdb.connect(host=muhconf["mysql"]["host"],
  191. port=muhconf["mysql"]["port"],
  192. user=muhconf["mysql"]["user"],
  193. passwd=muhconf["mysql"]["pass"],
  194. db=muhconf["mysql"]["db"],
  195. ssl={"cipher": "AES256-SHA"})
  196. else:
  197. db = MySQLdb.connect(host=muhconf["mysql"]["host"],
  198. port=muhconf["mysql"]["port"],
  199. user=muhconf["mysql"]["user"],
  200. passwd=muhconf["mysql"]["pass"],
  201. db=muhconf["mysql"]["db"])
  202. cursor = db.cursor()
  203. if muhconf["main"]["debug"]:
  204. printem("Connected y0")
  205. for sensor in muhconf["main"]["sensor"]:
  206. if muhconf["main"]["debug"]:
  207. printem("\tSensor: {0}".format(sensor))
  208. temperatures = []
  209. for i in range(1, 6): # Goes _up to_ 6 so the last iteration is i == 5 ;]
  210. text = '';
  211. attempts = 0
  212. while text.split("\n")[0].find("YES") == -1:
  213. if attempts >= muhconf["main"]["sample_maxattempts"]:
  214. plural = "s" if attempts > 1 else ""
  215. if muhconf["main"]["debug"]:
  216. printem("RIP readem_temp(): unable to read temperature after {0} attempt{1} (sample #{2})".format(attempts, plural, i))
  217. else:
  218. # Non-debug mode doesn't print the sensor name at an earlier point, so let's make it clear here ;;]];];
  219. printem("RIP readem_temp(): unable to read temperature for sensor {0} after {1} attempt{2} (sample #{3})".format(sensor, attempts, plural, i))
  220. sys.exit(RET_ERR_SENSOR)
  221. if attempts > 0:
  222. # The sensor needs a 1 second recalibration time
  223. time.sleep(1)
  224. # Contents of the file should be something leik dis:
  225. # 79 01 80 80 7f ff 7f 80 02 : crc=02 YES
  226. # 79 01 80 80 7f ff 7f 80 02 t=23562
  227. tfile = open("/sys/bus/w1/devices/{0}/w1_slave".format(sensor), "r")
  228. text = tfile.read()
  229. tfile.close()
  230. attempts += 1
  231. secondline = text.split("\n")[1]
  232. temperaturedata = secondline.split(" ")[9]
  233. temperature = float(temperaturedata[2:]) / 1000
  234. temperatures.append(temperature)
  235. if muhconf["main"]["debug"]:
  236. printem("\t\tSample #{0}: {1}".format(i, temperature))
  237. avgtemperatures.append(sum(temperatures) / float(len(temperatures)))
  238. if not muhconf["main"]["dryrun"]:
  239. if muhconf["main"]["debug"]:
  240. printem("\t\tInserting average: {0}".format(avgtemperatures[0]))
  241. cursor.execute("INSERT INTO `{0}` (`{1}`) VALUES ({2})".format(muhconf["mysql"]["table"], muhconf["mysql"]["column"], avgtemperatures[0]))
  242. db.commit()
  243. try_db_cleanup(db, cursor)
  244. except KeyboardInterrupt:
  245. printem("\nCTRL + C")
  246. try_db_cleanup(db, cursor)
  247. sys.exit(RET_GUCCI)
  248. except SystemExit:
  249. try_db_cleanup(db, cursor)
  250. sys.exit(RET_ERR_SENSOR)
  251. except:
  252. exc_info = sys.exc_info()
  253. printem("\nRIP readem_temp() ({0}): {1}".format(exc_info[0], exc_info[1]))
  254. try_db_cleanup(db, cursor)
  255. sys.exit(RET_ERR_SENSOR)
  256. return avgtemperatures
  257. def checkem_weathermon(inside_temp):
  258. # Get current state first so that if something goes wrong, we don't spend an API call lel
  259. statefile = "{0}/weathermon.state".format(skripdir)
  260. curstate = "outdoors_higher"
  261. notify = False
  262. try:
  263. if os.access(statefile, os.F_OK):
  264. sfile = open(statefile, "r")
  265. text = sfile.read().strip() # We'll write the file with a trailing newline, so trim that shit for ez comparison
  266. sfile.close()
  267. if text in ["outdoors_higher", "outdoors_lower"]:
  268. curstate = text
  269. else:
  270. if muhconf["main"]["debug"]:
  271. printem("\tState file exists but contents were unexpected, resetting to '{1}': '{0}'".format(text, curstate))
  272. except:
  273. # If we can't read the file we should bail entirely, as we can't reliably tell what to do anymore ;]
  274. exc_info = sys.exc_info()
  275. printem("\nRIP checkem_weathermon() state check ({0}): {1}".format(exc_info[0], exc_info[1]))
  276. sys.exit(RET_ERR_WEATHERMON)
  277. # Now try the API call imo tbh
  278. url = "{0}?units=metric&id={1}&appid={2}".format(muhconf["weathermon"]["weather_url"], muhconf["weathermon"]["city_id"], muhconf["weathermon"]["weather_key"])
  279. if muhconf["main"]["debug"]:
  280. printem("\tURL: {0}".format(url))
  281. try:
  282. req = requests.get(url, verify=muhconf["weathermon"]["tls_ca_file"])
  283. weather = req.json()
  284. outside_temp = weather["main"]["temp"]
  285. if outside_temp > inside_temp:
  286. if curstate == "outdoors_higher":
  287. if muhconf["main"]["debug"]:
  288. printem("\tAyy outside temp ({0}) is still ab0ve inside temp ({1}) br0, not proceeding".format(outside_temp, inside_temp))
  289. return
  290. else:
  291. curstate = "outdoors_higher" # Flip state lol
  292. if muhconf["main"]["debug"]:
  293. printem("\tAyy outside temp ({0}) is now ab0ve inside temp ({1}) br0, sending notification".format(outside_temp, inside_temp))
  294. notify = True
  295. elif outside_temp < inside_temp:
  296. if curstate == "outdoors_lower":
  297. if muhconf["main"]["debug"]:
  298. printem("\tAyy outside temp ({0}) is still bel0w inside temp ({1}) br0, not proceeding".format(outside_temp, inside_temp))
  299. return
  300. else:
  301. curstate = "outdoors_lower"
  302. if muhconf["main"]["debug"]:
  303. printem("\tAyy outside temp ({0}) is now bel0w inside temp ({1}) br0, sending notification".format(outside_temp, inside_temp))
  304. notify = True
  305. else:
  306. # Temps are equal, don't do anything cuz it could go either way lel
  307. if muhconf["main"]["debug"]:
  308. printem("\tAyy outside temp ({0}) is equal to inside temp ({1}) br0, not proceeding".format(outside_temp, inside_temp))
  309. return
  310. except:
  311. exc_info = sys.exc_info()
  312. printem("\nRIP checkem_weathermon() API call ({0}): {1}".format(exc_info[0], exc_info[1]))
  313. sys.exit(RET_ERR_WEATHERMON)
  314. # Update state file lol
  315. try:
  316. sfile = open(statefile, "w")
  317. sfile.write("{0}\n".format(curstate))
  318. sfile.close()
  319. except:
  320. # If we can't write the file we should bail entirely, as we can't reliably tell what to do anymore either ;]
  321. exc_info = sys.exc_info()
  322. printem("\nRIP checkem_weathermon() state update ({0}): {1}".format(exc_info[0], exc_info[1]))
  323. sys.exit(RET_ERR_WEATHERMON)
  324. # We'll send the notification as the very last thing cuz if something would go wrong with updating the state file, we won't keep repeating that shit ;]];;]];;];];]];
  325. if notify:
  326. try:
  327. if curstate == "outdoors_higher":
  328. msg = "Ayy outside temperature is now ab0ve inside temperature"
  329. else:
  330. msg = "Ayy outside temperature is now bel0w inside temperature"
  331. client = OneSignal(muhconf["weathermon"]["onesignal_restkey"], muhconf["weathermon"]["onesignal_appid"])
  332. segments = ['All']
  333. if muhconf["weathermon"]["onesignal_segment"] is not None:
  334. segments = [muhconf["weathermon"]["onesignal_segment"]]
  335. req = client.create_notification(
  336. # Arguments used by the library
  337. contents=msg,
  338. heading="Temperature state changed",
  339. url='',
  340. included_segments=segments,
  341. # These are already the defaults but putting that shit here for reference kek
  342. #app_id=None,
  343. #player_ids=None, # List or tuple, takes precedence over included_segments
  344. # Custom args to pass directly to the API
  345. android_channel_id=muhconf["weathermon"]["onesignal_channel"],
  346. huawei_channel_id=muhconf["weathermon"]["onesignal_channel"], # Is apparently a separate thing lmao
  347. )
  348. if not req.text.startswith("{"):
  349. printem("Unable to send notification: received invalid JSON response")
  350. printem("\t{0}".format(req.text))
  351. sys.exit(RET_ERR_WEATHERMON)
  352. notification = req.json()
  353. if req.status_code != 200:
  354. printem("Unable to send notification: received {0} HTTP status code instead of 200".format(req.status_code))
  355. printem("\t{0}".format(req.text))
  356. sys.exit(RET_ERR_WEATHERMON)
  357. if "errors" in notification and len(notification["errors"]):
  358. printem("Unable to send notification:")
  359. for err in notification["errors"]:
  360. printem("\t{0}".format(err))
  361. sys.exit(RET_ERR_WEATHERMON)
  362. if muhconf["main"]["debug"]:
  363. printem("\t{0}".format(req.text))
  364. except SystemExit:
  365. sys.exit(RET_ERR_WEATHERMON)
  366. except:
  367. exc_info = sys.exc_info()
  368. printem("\nRIP checkem_weathermon() notification ({0}): {1}".format(exc_info[0], exc_info[1]))
  369. sys.exit(RET_ERR_WEATHERMON)
  370. if __name__ == "__main__":
  371. dt = datetime.datetime.now()
  372. weathermon_mins = (dt.hour * 60) + dt.minute
  373. force_weathermon = False
  374. # Read config first lol
  375. readem_conf()
  376. # Then parse args imo tbh
  377. p = argparse.ArgumentParser()
  378. p.add_argument("--debug", help="force debug mode (current setting from config: {0})".format(muhconf["main"]["debug"]), action="store_true")
  379. p.add_argument("--dryrun", help="do almost everything as usual (connect to SQL, read temperature) but don't actually insert into the database (current setting from config: {0})".format(muhconf["main"]["dryrun"]), action="store_true")
  380. p.add_argument("--weathermon", help="force running weathermon (current interval from config: {0})".format(muhconf["weathermon"]["interval"]), action="store_true")
  381. p.add_argument("--version", help="print version and exit lol", action="store_true")
  382. args, noargs = p.parse_known_args() # noargs contains the 'topkek' in: ./temps.py --debug topkek
  383. if args.version:
  384. show_version()
  385. sys.exit(RET_GUCCI)
  386. if args.debug:
  387. muhconf["main"]["debug"] = True
  388. if args.dryrun:
  389. muhconf["main"]["dryrun"] = True
  390. if args.weathermon:
  391. force_weathermon = True
  392. if muhconf["main"]["debug"]:
  393. printem("Reading em temp")
  394. avgtemperatures = readem_temp()
  395. if muhconf["weathermon"]["enable"]:
  396. if len(avgtemperatures) == 0:
  397. printem("Wanted to run weathermon but avgtemperatures was unexpectedly empty lol")
  398. sys.exit(RET_ERR_WEATHERMON)
  399. if force_weathermon:
  400. if muhconf["main"]["debug"]:
  401. printem("Ayy forced to run weathermon fam")
  402. checkem_weathermon(avgtemperatures[0])
  403. else:
  404. weathermon_remaining = weathermon_mins % muhconf["weathermon"]["interval"]
  405. if muhconf["main"]["debug"]:
  406. printem("Checking if we need to run weathermon: {0} % {1} = {2}".format(weathermon_mins, muhconf["weathermon"]["interval"], weathermon_remaining))
  407. if weathermon_remaining == 0:
  408. if muhconf["main"]["debug"]:
  409. printem("Ayy running weathermon fam")
  410. checkem_weathermon(avgtemperatures[0])
  411. else:
  412. if muhconf["main"]["debug"]:
  413. printem("Not running weathermon fam")
  414. sys.exit(RET_GUCCI) # Let's do an explicit exit w/ code imo tbh famlamlaml