rd200.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. ## RaydonEye uses BLE LED Button Service (LBS) UUID 00001523-1212-EFDE-1523-785FEABCD123
  2. ## [!] ble only supports a single connection, if RadonEye App is running this will fail
  3. import BLE_GATT
  4. from gi.repository import GLib
  5. import subprocess
  6. import signal
  7. import time
  8. import struct
  9. import sqlite3
  10. import requests
  11. import sys
  12. import os
  13. import errno
  14. BLUETOOTH_LBS_SERVICE_CHARACTERISTIC = '00001523-1212-EFDE-1523-785FEABCD123'
  15. BLUETOOTH_LBS_NOTIFY_READ_CHARACTERISTIC = '00001525-1212-EFDE-1523-785FEABCD123'
  16. BLUETOOTH_LBS_WRITE_CHARACTERISTIC = '00001524-1212-EFDE-1523-785FEABCD123'
  17. # second is 'S' C2:58:00:9A:26:29
  18. # first purchased 'R' CF:CD:27:79:55:6B
  19. def getName(dbName = False):
  20. sensor = 'CF:CD:27:79:55:6B' if (2 > len(sys.argv)) else sys.argv[1]
  21. return 'radonEye' + sensor.replace(':', '-') + '.db' if dbName else sensor
  22. def connect(radonEyeMac, dev = None):
  23. """
  24. Use Central Role BlueZ D-Bus API (BLE-GATT) to connect to peripheral (RadonEye)
  25. """
  26. print("connecting to radon sensor: ", radonEyeMac)
  27. while True:
  28. try:
  29. dev = BLE_GATT.Central(radonEyeMac)
  30. # 2022 Aug 18th trying to fix a bug where we get stuck in an
  31. # infinite loop, I think it is due to BLE_GATT.Central.connect()
  32. # having a while condition sleep .5 - I find rd200.py strace stuck
  33. # in a select(tv_usec=500000) loop
  34. #dev.connect()
  35. ## replacement BLE_GATT.Central.connect() <-------------------
  36. dev.device.Connect()
  37. max = 10
  38. while not dev.device.ServicesResolved:
  39. sleep(0.5)
  40. max -= 1
  41. if 1 > max:
  42. raise Exception("waiting for GATT services to resolve")
  43. dev._get_device_chars()
  44. ## end replacement ------------------>
  45. break
  46. except:
  47. print("device not found, retrying in a second...\n")
  48. time.sleep(1)
  49. if(False == ble_ctl(radonEyeMac)):
  50. # try again with a power cycle
  51. print("try to recover on org.bluez.error...\n")
  52. time.sleep(1)
  53. weakAttempt(radonEyeMac)
  54. continue
  55. return dev
  56. def weakAttempt(radonEyeMac):
  57. """
  58. Tries to handle org.bluez.Error.InProgress errors, this is not a very strong
  59. but does help in this specific situation without needing to do additional
  60. stuff - there is still a problem that another program can sever the BLE
  61. interface so be careful about using this
  62. """
  63. lockFile = ".rd200-lockfile"
  64. try:
  65. # use a file as a lock (exclusive create)
  66. f = open(lockFile, mode='x')
  67. ble_ctl(radonEyeMac, powerCycle = True)
  68. # delete the file
  69. f.close()
  70. os.remove(lockFile)
  71. except Exception as e:
  72. if(errno.EEXIST == e.errno):
  73. # make sure the file lock is not broken
  74. try:
  75. old = os.stat(lockFile)
  76. if(60 < (int(time.time()) - int(old.st_mtime))):
  77. print("lock was borked, cleaning up...\n")
  78. os.remove(lockFile)
  79. except Exception as e2:
  80. print("failed to cleanup borked lock", e2)
  81. else:
  82. print("weakAttempt did not fix org.bluez.Error.InProgress", e)
  83. # [!] To get this to work bluetoothctl needs to connect to the device before using BLE_GATT.connect
  84. def ble_ctl(addr:str, retry = 50, powerCycle = False):
  85. """
  86. use bluetoothctl interactively:
  87. scan on,
  88. ... wait ...,
  89. scan off,
  90. connect CF:CD:27:79:55:6B,
  91. exit
  92. [!] subprocess has a relevant bug in line-buffering, see issue 21471
  93. """
  94. def writeHelper(proc, str, delay = 1):
  95. proc.stdin.write(str.encode('utf-8'))
  96. proc.stdin.flush()
  97. time.sleep(delay)
  98. # get a PTY-like interface to control the bluetoothctl program
  99. process = subprocess.Popen(['bluetoothctl'], stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE)
  100. # optional, handle "org.bluez.Error.InProgress" using bluetoothctl
  101. if(True == powerCycle):
  102. writeHelper(process, "power off\n", 2)
  103. writeHelper(process, "power on\n", 2)
  104. # send bluetoothctl commands interactively
  105. writeHelper(process, "scan on\n", 12)
  106. """
  107. [!] technically we should wait here until we get the BLE peripheral advertisement,
  108. some random internet document says: 4.4.2.2.1 Advertising Interval is integer
  109. multiple of 0.625ms in the range of 20 milliseconds to 10.24 seconds
  110. """
  111. writeHelper(process, "scan off\n")
  112. writeHelper(process, "connect " + addr + "\n", 3)
  113. writeHelper(process, "exit\n")
  114. # wait for child subprocess to terminate, or kill after five seconds
  115. while True:
  116. if None != process.poll():
  117. break
  118. time.sleep(.1)
  119. if 0 < retry:
  120. retry -= 1
  121. else:
  122. print("bluetoothctl did not close in time, terminating...")
  123. process.terminate()
  124. try:
  125. out, err = process.communicate()
  126. except Exception as e:
  127. print("tried to close subprocess stdin but got error: ", e)
  128. # compress logging when bluetoothctl reports: "No default controller available"
  129. if(0 < out.decode("utf-8").count("No default controller available")):
  130. print("bluetoothctl reports reports no default controller")
  131. # in this case bluetoothctl power off / on does nothing...
  132. # if this is logged then bt-snuggler did not prepare environment
  133. # check for possible bluetoothctl error:
  134. # "Failed to start discovery: org.bluez.Error.InProgress"
  135. if(0 < out.decode("utf-8").count("org.bluez.Error")):
  136. return False
  137. # convert RD200 LBS response to integer value
  138. def radonEye_hundredths(res):
  139. """
  140. radonEye can only display 0.20 - 99.00 distinct values, internally they store
  141. each value as a IEEE754 value (4-byte big-endian float) -- 32 bits is a bit
  142. overkill when 12 would suffice, but we are not going to save space, instead,
  143. to simplify communication, we are going to send not picocuries per liter, and
  144. instead hundredths of picocurries per liter since this is the best resolution
  145. the radonEye can support and conversion to picocuries is divide by one hundred
  146. """
  147. return round(100 * struct.unpack('<f', bytes(res[2:6]))[0])
  148. # write 0x50 to LBS, unless disconnected teardown
  149. def radonEye_poll(LBS_device):
  150. try:
  151. print("timeout, pressing button")
  152. LBS_device.char_write(BLUETOOTH_LBS_WRITE_CHARACTERISTIC, b'\x50')
  153. except:
  154. print("device is not connected - attempting to cleanup")
  155. try:
  156. # remove notifications, exit event loop, disconnect device
  157. LBS_device.cleanup()
  158. except Exception as e:
  159. print("failed to cleanup BLE_GATT objects because: ", e)
  160. return False
  161. return True
  162. def mmcgo_notify(value):
  163. # assignment of device name is aliased for the frontend
  164. device = 'R' if (getName() == 'CF:CD:27:79:55:6B') else 'S'
  165. try:
  166. # generates a noise about not verifying certificates
  167. #requests.post("https://mmcgo.com/radon/index.php", verify = False, data = {'type' : 'submitReadings', device : value / 100})
  168. requests.post("http://mmcgo.com/radon/index.php", data = {'type' : 'submitReadings', device : value / 100})
  169. except Exception as e:
  170. print("failed to send data to mmcgo.com: ", e)
  171. def saveData(value):
  172. print(" ".join(hex(x) for x in value))
  173. value = radonEye_hundredths(value)
  174. print(value / 100, " picocuries per liter")
  175. if(0 == value):
  176. print("RadonEye is probably in calibration, please wait...\n")
  177. return True
  178. mmcgo_notify(value)
  179. db = sqlite3.connect(getName(True))
  180. cu = db.cursor()
  181. cu.execute('SELECT value FROM samples WHERE rowid = (SELECT MAX(rowid) FROM samples)')
  182. last = cu.fetchone()
  183. if None == last or value != last[0]:
  184. print("saving...")
  185. try:
  186. cu.execute('INSERT INTO samples VALUES (?, ?)', (int(time.time()), value))
  187. db.commit()
  188. except Exception as e:
  189. print("python3 SQLite3 wrapper raised an error: ", e)
  190. db.close()
  191. def handler(signum, frame):
  192. if signal.SIGINT == signum:
  193. print("goodbye")
  194. exit()
  195. def main():
  196. # terminate on [ctrl] + [c]
  197. signal.signal(signal.SIGINT, handler)
  198. # create a local database
  199. try:
  200. fd = open(getName(True))
  201. fd.close()
  202. except:
  203. db = sqlite3.connect(getName(True))
  204. cu = db.cursor()
  205. cu.execute('CREATE TABLE samples (time INTEGER, value INTEGER)')
  206. db.commit()
  207. db.close()
  208. while True:
  209. dev = connect(getName())
  210. while True:
  211. # EcoSense rd200 would notify via BLUETOOTH_LBS_NOTIFY_READ_CHARACTERISTIC,
  212. # but either GLib.timeout_add_seconds or BLE_GATT.on_value_change and
  213. # BLE_GATT.wait_for_notifications() will call operating system poll with
  214. # a value of -1 for timeout causing the program to hang indefinetly
  215. # [!] instead, just wait half a second for the characteristic to be read ready
  216. try:
  217. dev.char_write(BLUETOOTH_LBS_WRITE_CHARACTERISTIC, b'\x50')
  218. time.sleep(.55)
  219. value = dev.char_read(BLUETOOTH_LBS_NOTIFY_READ_CHARACTERISTIC)
  220. except:
  221. # get out of the polling loop and let main loop recover
  222. break
  223. saveData(value)
  224. # wait for next sample
  225. time.sleep(300)
  226. print("device is not connected - attempting to cleanup\n")
  227. try:
  228. dev.disconnect()
  229. except Exception as e:
  230. print("failed to cleanup BLE_GATT objects because: ", e)
  231. print('restarting...\n')
  232. continue
  233. if(__name__ == "__main__"):
  234. main()