miflora-mqtt-daemon.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. #!/usr/bin/env python3
  2. import sys
  3. import re
  4. import json
  5. import os.path
  6. from time import sleep, localtime, strftime
  7. from colorama import init as colorama_init
  8. from colorama import Fore, Back, Style
  9. from configparser import ConfigParser
  10. from unidecode import unidecode
  11. from miflora.miflora_poller import MiFloraPoller, MI_BATTERY, MI_CONDUCTIVITY, MI_LIGHT, MI_MOISTURE, MI_TEMPERATURE
  12. import paho.mqtt.client as mqtt
  13. import sdnotify
  14. parameters = {MI_BATTERY: dict(pretty='Sensor Battery Level', typeformat='%d', unit='%'),
  15. MI_CONDUCTIVITY: dict(pretty='Soil Conductivity/Fertility', typeformat='%d', unit='µS/cm'),
  16. MI_LIGHT: dict(pretty='Sunlight Intensity', typeformat='%d', unit='lux'),
  17. MI_MOISTURE: dict(pretty='Soil Moisture', typeformat='%d', unit='%'),
  18. MI_TEMPERATURE: dict(pretty='Air Temperature', typeformat='%.1f', unit='°C')}
  19. # Intro
  20. colorama_init()
  21. print(Fore.GREEN + Style.BRIGHT)
  22. print('Xiaomi Mi Flora Plant Sensor MQTT Client/Daemon')
  23. print('Source: https://github.com/ThomDietrich/miflora-mqtt-daemon')
  24. print(Style.RESET_ALL)
  25. if False:
  26. print('Sorry, this script requires a python3 runtime environemt.', file=sys.stderr)
  27. # Systemd Service Notifications - https://github.com/bb4242/sdnotify
  28. sd_notifier = sdnotify.SystemdNotifier()
  29. def print_line(text, error = False, warning=False, sd_notify=False, console=True):
  30. timestamp = strftime('%Y-%m-%d %H:%M:%S', localtime())
  31. if console:
  32. if error:
  33. print(Fore.RED + Style.BRIGHT + '[{}] '.format(timestamp) + Style.RESET_ALL + '{}'.format(text) + Style.RESET_ALL, file=sys.stderr)
  34. elif warning:
  35. print(Fore.YELLOW + '[{}] '.format(timestamp) + Style.RESET_ALL + '{}'.format(text) + Style.RESET_ALL)
  36. else:
  37. print(Fore.GREEN + '[{}] '.format(timestamp) + Style.RESET_ALL + '{}'.format(text) + Style.RESET_ALL)
  38. timestamp_sd = strftime('%b %d %H:%M:%S', localtime())
  39. if sd_notify:
  40. sd_notifier.notify('STATUS={} - {}.'.format(timestamp_sd, unidecode(text)))
  41. # Eclipse Paho callbacks - http://www.eclipse.org/paho/clients/python/docs/#callbacks
  42. def on_connect(client, userdata, flags, rc):
  43. if rc == 0:
  44. print_line('MQTT connection established', console=True, sd_notify=True)
  45. print()
  46. else:
  47. print_line('Connection error with result code {} - {}'.format(str(rc), mqtt.connack_string(rc)), error=True)
  48. #kill main thread
  49. os._exit(1)
  50. def on_publish(client, userdata, mid):
  51. #print_line('Data successfully published.')
  52. pass
  53. def flores_to_openhab_items(flores, reporting_mode):
  54. print_line('Generating openHAB "miflora.items" file ...')
  55. items = list()
  56. items.append('// miflora.items - Generated by miflora-mqtt-daemon.')
  57. items.append('// Adapt to your needs! Things you probably want to modify:')
  58. items.append('// Room group names, icons,')
  59. items.append('// "gAll", "broker", "UnknownRoom"')
  60. items.append('')
  61. items.append('// Mi Flora specific groups')
  62. for param, param_properties in parameters.items():
  63. items.append('Group g{} "Mi Flora {} elements" (gAll)'.format(param.capitalize(), param_properties['pretty']))
  64. if reporting_mode == 'mqtt-json':
  65. for [flora_name, flora] in flores.items():
  66. location = flora['location'] if flora['location'] else 'UnknownRoom'
  67. items.append('\n// Mi Flora "{}" ({})'.format(flora['pretty'], flora['mac']))
  68. for [param, param_properties] in parameters.items():
  69. basic = 'Number {}_{}_{}'.format(location, flora_name.capitalize(), param.capitalize())
  70. label = '"{} {} {} [{} {}]"'.format(location, flora['pretty'], param_properties['pretty'], param_properties['typeformat'], param_properties['unit'].replace('%', '%%'))
  71. details = '<text> (g{}, g{})'.format(location, param.capitalize())
  72. channel = '{{mqtt="<[broker:{}/{}:state:JSONPATH($.{})]"}}'.format(base_topic, flora_name, param)
  73. items.append(' '.join([basic, label, details, channel]))
  74. items.append('')
  75. print('\n'.join(items))
  76. #elif reporting_mode == 'mqtt-homie':
  77. else:
  78. raise IOError('Given reporting_mode not supported for the export to openHAB items')
  79. # Load configuration file
  80. config = ConfigParser(delimiters=('=', ))
  81. config.optionxform = str
  82. config.read([os.path.join(sys.path[0], 'config.ini.dist'), os.path.join(sys.path[0], 'config.ini')])
  83. reporting_mode = config['General'].get('reporting_method', 'mqtt-json')
  84. daemon_enabled = config['Daemon'].getboolean('enabled', True)
  85. base_topic = config['MQTT'].get('base_topic', 'homie' if reporting_mode == 'mqtt-homie' else 'miflora').lower()
  86. device_id = config['MQTT'].get('homie_device_id', 'miflora-mqtt-daemon').lower()
  87. sleep_period = config['Daemon'].getint('period', 300)
  88. miflora_cache_timeout = sleep_period - 1
  89. # Check configuration
  90. if not reporting_mode in ['mqtt-json', 'mqtt-homie', 'json']:
  91. print_line('Configuration parameter reporting_mode set to an invalid value', error=True, sd_notify=True)
  92. sys.exit(1)
  93. if not config['Sensors']:
  94. print_line('No sensors found in configuration file "config.ini"', error=True, sd_notify=True)
  95. sys.exit(1)
  96. print_line('Configuration accepted', console=False, sd_notify=True)
  97. # MQTT connection
  98. if reporting_mode in ['mqtt-json', 'mqtt-homie']:
  99. print_line('Connecting to MQTT broker ...')
  100. mqtt_client = mqtt.Client()
  101. mqtt_client.on_connect = on_connect
  102. mqtt_client.on_publish = on_publish
  103. if reporting_mode == 'mqtt-json':
  104. mqtt_client.will_set('{}/$announce'.format(base_topic), payload='{}', retain=True)
  105. elif reporting_mode == 'mqtt-homie':
  106. mqtt_client.will_set('{}/{}/$online'.format(base_topic, device_id), payload='false', retain=True)
  107. if config['MQTT'].get('username'):
  108. mqtt_client.username_pw_set(config['MQTT'].get('username'), config['MQTT'].get('password', None))
  109. try:
  110. mqtt_client.connect(config['MQTT'].get('hostname', 'localhost'),
  111. port=config['MQTT'].getint('port', 1883),
  112. keepalive=config['MQTT'].getint('keepalive', 60))
  113. except:
  114. print_line('MQTT connection error. Please check your settings in the configuration file "config.ini"', error=True, sd_notify=True)
  115. sys.exit(1)
  116. else:
  117. mqtt_client.loop_start()
  118. sleep(1.0) # some slack to establish the connection
  119. sd_notifier.notify('READY=1')
  120. # Initialize Mi Flora sensors
  121. flores = dict()
  122. for [name, mac] in config['Sensors'].items():
  123. if not re.match("C4:7C:8D:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}", mac):
  124. print_line('The MAC address "{}" seems to be in the wrong format. Please check your configuration'.format(mac), error=True, sd_notify=True)
  125. sys.exit(1)
  126. location = ''
  127. if '@' in name:
  128. name, location = name.split("@")
  129. location = location.replace(' ', '-')
  130. name_pretty = name
  131. name_clean = name.lower()
  132. for o, n in [[' ', '-'], ['ä', 'ae'], ['ö', 'oe'], ['ü', 'ue'], ['ß', 'ss']]:
  133. name_clean = name_clean.replace(o, n)
  134. name_clean = unidecode(name_clean)
  135. flora = dict()
  136. print('Adding sensor to device list and testing connection ...')
  137. print('Name: "{}"'.format(name_pretty))
  138. #print_line('Attempting initial connection to Mi Flora sensor "{}" ({})'.format(name_pretty, mac), console=False, sd_notify=True)
  139. flora_poller = MiFloraPoller(mac=mac, cache_timeout=miflora_cache_timeout, retries=3)
  140. flora['poller'] = flora_poller
  141. flora['pretty'] = name_pretty
  142. flora['mac'] = flora_poller._mac
  143. flora['refresh'] = sleep_period
  144. flora['location'] = location
  145. flora['stats'] = {"count": 0, "success": 0, "failure": 0}
  146. try:
  147. flora_poller.fill_cache()
  148. flora_poller.parameter_value(MI_LIGHT)
  149. flora['firmware'] = flora_poller.firmware_version()
  150. except IOError:
  151. print_line('Failed to retrieve data from Mi Flora sensor "{}" ({}) during initial connection.'.format(name_pretty, mac), error=True, sd_notify=True)
  152. else:
  153. print('Internal name: "{}"'.format(name_clean))
  154. print('Device name: "{}"'.format(flora_poller.name()))
  155. print('MAC address: {}'.format(flora_poller._mac))
  156. print('Firmware: {}'.format(flora_poller.firmware_version()))
  157. print_line('Initial connection to Mi Flora sensor "{}" ({}) successful'.format(name_pretty, mac), sd_notify=True)
  158. print()
  159. flores[name_clean] = flora
  160. # Discovery Announcement
  161. if reporting_mode == 'mqtt-json':
  162. print_line('Announcing Mi Flora devices to MQTT broker for auto-discovery ...')
  163. flores_info = dict()
  164. for [flora_name, flora] in flores.items():
  165. flora_info = {key: value for key, value in flora.items() if key not in ['poller', 'stats']}
  166. flora_info['topic'] = '{}/{}'.format(base_topic, flora_name)
  167. flores_info[flora_name] = flora_info
  168. mqtt_client.publish('{}/$announce'.format(base_topic), json.dumps(flores_info), retain=True)
  169. sleep(0.5) # some slack for the publish roundtrip and callback function
  170. print()
  171. elif reporting_mode == 'mqtt-homie':
  172. print_line('Announcing Mi Flora devices to MQTT broker for auto-discovery ...')
  173. mqtt_client.publish('{}/{}/$homie'.format(base_topic, device_id), '2.1.0-alpha', 1, True)
  174. mqtt_client.publish('{}/{}/$online'.format(base_topic, device_id), 'true', 1, True)
  175. mqtt_client.publish('{}/{}/$name'.format(base_topic, device_id), device_id, 1, True)
  176. mqtt_client.publish('{}/{}/$fw/version'.format(base_topic, device_id), flora['firmware'], 1, True)
  177. nodes_list = ','.join([flora_name for [flora_name, flora] in flores.items()])
  178. mqtt_client.publish('{}/{}/$nodes'.format(base_topic, device_id), nodes_list, 1, True)
  179. for [flora_name, flora] in flores.items():
  180. topic_path = '{}/{}/{}'.format(base_topic, device_id, flora_name)
  181. mqtt_client.publish('{}/$name'.format(topic_path), flora['pretty'], 1, True)
  182. mqtt_client.publish('{}/$type'.format(topic_path), 'miflora', 1, True)
  183. mqtt_client.publish('{}/$properties'.format(topic_path), 'battery,conductivity,light,moisture,temperature', 1, True)
  184. mqtt_client.publish('{}/battery/$settable'.format(topic_path), 'false', 1, True)
  185. mqtt_client.publish('{}/battery/$unit'.format(topic_path), 'percent', 1, True)
  186. mqtt_client.publish('{}/battery/$datatype'.format(topic_path), 'int', 1, True)
  187. mqtt_client.publish('{}/battery/$range'.format(topic_path), '0:100', 1, True)
  188. mqtt_client.publish('{}/conductivity/$settable'.format(topic_path), 'false', 1, True)
  189. mqtt_client.publish('{}/conductivity/$unit'.format(topic_path), 'µS/cm', 1, True)
  190. mqtt_client.publish('{}/conductivity/$datatype'.format(topic_path), 'int', 1, True)
  191. mqtt_client.publish('{}/conductivity/$range'.format(topic_path), '0:*', 1, True)
  192. mqtt_client.publish('{}/light/$settable'.format(topic_path), 'false', 1, True)
  193. mqtt_client.publish('{}/light/$unit'.format(topic_path), 'lux', 1, True)
  194. mqtt_client.publish('{}/light/$datatype'.format(topic_path), 'int', 1, True)
  195. mqtt_client.publish('{}/light/$range'.format(topic_path), '0:50000', 1, True)
  196. mqtt_client.publish('{}/moisture/$settable'.format(topic_path), 'false', 1, True)
  197. mqtt_client.publish('{}/moisture/$unit'.format(topic_path), 'percent', 1, True)
  198. mqtt_client.publish('{}/moisture/$datatype'.format(topic_path), 'int', 1, True)
  199. mqtt_client.publish('{}/moisture/$range'.format(topic_path), '0:100', 1, True)
  200. mqtt_client.publish('{}/temperature/$settable'.format(topic_path), 'false', 1, True)
  201. mqtt_client.publish('{}/temperature/$unit'.format(topic_path), '°C', 1, True)
  202. mqtt_client.publish('{}/temperature/$datatype'.format(topic_path), 'float', 1, True)
  203. mqtt_client.publish('{}/temperature/$range'.format(topic_path), '*', 1, True)
  204. sleep(0.5) # some slack for the publish roundtrip and callback function
  205. print()
  206. print_line('Initialization complete, starting MQTT publish loop', console=False, sd_notify=True)
  207. flores_to_openhab_items(flores, reporting_mode)
  208. # Sensor data retrieval and publication
  209. while True:
  210. for [flora_name, flora] in flores.items():
  211. data = dict()
  212. attempts = 2
  213. flora['poller']._cache = None
  214. flora['poller']._last_read = None
  215. flora['stats']['count'] = flora['stats']['count'] + 1
  216. print_line('Retrieving data from sensor "{}" ...'.format(flora['pretty']))
  217. while attempts != 0 and not flora['poller']._cache:
  218. try:
  219. flora['poller'].fill_cache()
  220. flora['poller'].parameter_value(MI_LIGHT)
  221. except IOError:
  222. attempts = attempts - 1
  223. if attempts > 0:
  224. print_line('Retrying ...', warning = True)
  225. flora['poller']._cache = None
  226. flora['poller']._last_read = None
  227. if not flora['poller']._cache:
  228. flora['stats']['failure'] = flora['stats']['failure'] + 1
  229. print_line('Failed to retrieve data from Mi Flora sensor "{}" ({}), success rate: {:.0%}'.format(
  230. flora['pretty'], flora['mac'], flora['stats']['success']/flora['stats']['count']
  231. ), error = True, sd_notify = True)
  232. print()
  233. continue
  234. else:
  235. flora['stats']['success'] = flora['stats']['success'] + 1
  236. for param,_ in parameters.items():
  237. data[param] = flora['poller'].parameter_value(param)
  238. print_line('Result: {}'.format(json.dumps(data)))
  239. if reporting_mode == 'mqtt-json':
  240. print_line('Publishing to MQTT topic "{}/{}"'.format(base_topic, flora_name))
  241. mqtt_client.publish('{}/{}'.format(base_topic, flora_name), json.dumps(data))
  242. sleep(0.5) # some slack for the publish roundtrip and callback function
  243. elif reporting_mode == 'mqtt-homie':
  244. print_line('Publishing data to MQTT base topic "{}/{}/{}"'.format(base_topic, device_id, flora_name))
  245. for [param, value] in data.items():
  246. mqtt_client.publish('{}/{}/{}/{}'.format(base_topic, device_id, flora_name, param), value, 1, False)
  247. sleep(0.5) # some slack for the publish roundtrip and callback function
  248. elif reporting_mode == 'json':
  249. data['timestamp'] = strftime('%Y-%m-%d %H:%M:%S', localtime())
  250. data['name'] = flora_name
  251. data['pretty_name'] = flora['pretty']
  252. data['mac'] = flora['mac']
  253. data['firmware'] = flora['firmware']
  254. print('Data for "{}": {}'.format(flora_name, json.dumps(data)))
  255. else:
  256. raise NameError('Unexpected reporting_mode.')
  257. print()
  258. print_line('Status messages published', console=False, sd_notify=True)
  259. if daemon_enabled:
  260. print_line('Sleeping ({} seconds) ...'.format(sleep_period))
  261. sleep(sleep_period)
  262. print()
  263. else:
  264. print_line('Execution finished in non-daemon-mode', sd_notify=True)
  265. if reporting_mode == 'mqtt-json':
  266. mqtt_client.disconnect()
  267. break