miflora-mqtt-daemon.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  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 configparser import ConfigParser
  8. import unicodedata
  9. from miflora.miflora_poller import MiFloraPoller, MI_BATTERY, MI_CONDUCTIVITY, MI_LIGHT, MI_MOISTURE, MI_TEMPERATURE
  10. import paho.mqtt.client as mqtt
  11. import sdnotify
  12. parameters = [MI_BATTERY, MI_CONDUCTIVITY, MI_LIGHT, MI_MOISTURE, MI_TEMPERATURE]
  13. # Intro
  14. print('Xiaomi Mi Flora Plant Sensor MQTT Client/Daemon')
  15. print('Source: https://github.com/ThomDietrich/miflora-mqtt-daemon')
  16. print()
  17. if False:
  18. print('Sorry, this script requires a python3 runtime environemt.', file=sys.stderr)
  19. # Systemd Service Notifications - https://github.com/bb4242/sdnotify
  20. sd_notifier = sdnotify.SystemdNotifier()
  21. # Eclipse Paho callbacks - http://www.eclipse.org/paho/clients/python/docs/#callbacks
  22. def on_connect(client, userdata, flags, rc):
  23. if rc == 0:
  24. print('Connected.\n')
  25. sd_notifier.notify('STATUS=MQTT connection established')
  26. else:
  27. print('Connection error with result code {} - {}'.format(str(rc), mqtt.connack_string(rc)), file=sys.stderr)
  28. #kill main thread
  29. os._exit(1)
  30. def on_publish(client, userdata, mid):
  31. #print('Data successfully published.')
  32. pass
  33. def flores_to_openhab_items(flores):
  34. items = list()
  35. items.append('// Generated by miflora-mqtt-daemon. Adapt to your needs!\n\n// Mi Flora specific groups')
  36. for param in parameters:
  37. items.append('Group g{} "Mi Flora{} elements" (gAll)'.format(param.capitalize(), param.capitalize()))
  38. for [flora_name, flora] in flores.items():
  39. items.append('\n// Mi Flora "{}" ({})'.format(flora['pretty'], flora['mac']))
  40. for param in parameters:
  41. basic = 'Number {}_{}'.format(flora['location'], flora_name.capitalize())
  42. parameter = '"{} {} {} [%.0f]" <text> (g{}, g{})'.format(flora['location'], flora['pretty'], param.capitalize(), flora['location'], param.capitalize())
  43. channel = '{{mqtt="<[broker:{}/{}:state:JSONPATH($.{})]"}}'.format(base_topic, flora_name, param)
  44. items.append(' '.join([basic, parameter, channel]))
  45. print('\n'.join(items))
  46. # Load configuration file
  47. config = ConfigParser(delimiters=('=', ))
  48. config.optionxform = str
  49. config.read([os.path.join(sys.path[0], 'config.ini'), os.path.join(sys.path[0], 'config.local.ini')])
  50. reporting_mode = config['General'].get('reporting_method', 'mqtt-json')
  51. daemon_enabled = config['Daemon'].getboolean('enabled', True)
  52. base_topic = config['MQTT'].get('base_topic', 'homie' if reporting_mode == 'mqtt-homie' else 'miflora').lower()
  53. device_id = config['MQTT'].get('homie_device_id', 'miflora-mqtt-daemon').lower()
  54. sleep_period = config['Daemon'].getint('period', 300)
  55. #miflora_cache_timeout = config['MiFlora'].getint('cache_timeout', 600)
  56. miflora_cache_timeout = sleep_period - 1
  57. # Check configuration
  58. if not reporting_mode in ['mqtt-json', 'mqtt-homie', 'json']:
  59. print('Error. Configuration parameter reporting_mode set to an invalid value.', file=sys.stderr)
  60. sd_notifier.notify('STATUS=Configuration parameter reporting_mode set to an invalid value')
  61. sys.exit(1)
  62. if not config['Sensors']:
  63. print('Error. Please add at least one sensor to the configuration file "config.ini".', file=sys.stderr)
  64. print('Scan for available Miflora sensors with "sudo hcitool lescan".', file=sys.stderr)
  65. sd_notifier.notify('STATUS=No sensors found in configuration file "config.ini"')
  66. sys.exit(1)
  67. sd_notifier.notify('STATUS=Configuration accepted')
  68. # MQTT connection
  69. if reporting_mode in ['mqtt-json', 'mqtt-homie']:
  70. print('Connecting to MQTT broker ...')
  71. mqtt_client = mqtt.Client()
  72. mqtt_client.on_connect = on_connect
  73. mqtt_client.on_publish = on_publish
  74. if reporting_mode == 'mqtt-json':
  75. mqtt_client.will_set('{}/$announce'.format(base_topic), payload='{}', retain=True)
  76. elif reporting_mode == 'mqtt-homie':
  77. mqtt_client.will_set('{}/{}/$online'.format(base_topic, device_id), payload='false', retain=True)
  78. if config['MQTT'].get('username'):
  79. mqtt_client.username_pw_set(config['MQTT'].get('username'), config['MQTT'].get('password', None))
  80. try:
  81. mqtt_client.connect(config['MQTT'].get('hostname', 'localhost'),
  82. port=config['MQTT'].getint('port', 1883),
  83. keepalive=config['MQTT'].getint('keepalive', 60))
  84. except:
  85. print('Error. Please check your MQTT connection settings in the configuration file "config.ini".', file=sys.stderr)
  86. sd_notifier.notify('STATUS=Please check your MQTT connection settings in the configuration file "config.ini"')
  87. sys.exit(1)
  88. else:
  89. mqtt_client.loop_start()
  90. sleep(1.0) # some slack to establish the connection
  91. sd_notifier.notify('READY=1')
  92. # Initialize Mi Flora sensors
  93. flores = dict()
  94. for [name, mac] in config['Sensors'].items():
  95. if not re.match("C4:7C:8D:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}", mac):
  96. print('Error. The MAC address "{}" seems to be in the wrong format. Please check your configuration.'.format(mac), file=sys.stderr)
  97. sd_notifier.notify('STATUS=The MAC address "{}" seems to be in the wrong format. Please check your configuration.'.format(mac))
  98. sys.exit(1)
  99. location = ''
  100. if '@' in name:
  101. name, location = name.split("@")
  102. location = location.replace(' ', '-')
  103. name_pretty = name
  104. name_clean = unicodedata.normalize('NFKD', name.lower()).replace(' ', '-')
  105. flora = dict()
  106. print('Adding sensor to device list and testing connection ...')
  107. print('Name: "{}"'.format(name_pretty))
  108. sd_notifier.notify('STATUS=Attempting initial connection to Mi Flora sensor "{}" ({})'.format(name_pretty, mac))
  109. flora_poller = MiFloraPoller(mac=mac, cache_timeout=miflora_cache_timeout, retries=9)
  110. flora['poller'] = flora_poller
  111. flora['pretty'] = name_pretty
  112. flora['mac'] = flora_poller._mac
  113. flora['refresh'] = sleep_period
  114. flora['location'] = location
  115. try:
  116. flora_poller.fill_cache()
  117. flora_poller.parameter_value(MI_LIGHT)
  118. flora['firmware'] = flora_poller.firmware_version()
  119. except IOError:
  120. print('Error. Initial connection to Mi Flora sensor "{}" ({}) failed. Please check your setup and the MAC address.'.format(name_pretty, mac), file=sys.stderr)
  121. sd_notifier.notify('STATUS=Initial connection to Mi Flora sensor "{}" ({}) failed'.format(name_pretty, mac))
  122. continue
  123. else:
  124. print('Internal name: "{}"'.format(name_clean))
  125. print('Device name: "{}"'.format(flora_poller.name()))
  126. print('MAC address: {}'.format(flora_poller._mac))
  127. print('Firmware: {}'.format(flora_poller.firmware_version()))
  128. print()
  129. flores[name_clean] = flora
  130. # Discovery Announcement
  131. if reporting_mode == 'mqtt-json':
  132. print('Announcing Mi Flora devices to MQTT broker for auto-discovery ...')
  133. flores_info = dict()
  134. for [flora_name, flora] in flores.items():
  135. flora_info = {key: value for key, value in flora.items() if key not in ['poller']}
  136. flora_info['topic'] = '{}/{}'.format(base_topic, flora_name)
  137. flores_info[flora_name] = flora_info
  138. mqtt_client.publish('{}/$announce'.format(base_topic), json.dumps(flores_info), retain=True)
  139. sleep(0.5) # some slack for the publish roundtrip and callback function
  140. print()
  141. elif reporting_mode == 'mqtt-homie':
  142. print('Announcing Mi Flora devices to MQTT broker for auto-discovery ...')
  143. mqtt_client.publish('{}/{}/$homie'.format(base_topic, device_id), '2.1.0-alpha', 1, True)
  144. mqtt_client.publish('{}/{}/$online'.format(base_topic, device_id), 'true', 1, True)
  145. mqtt_client.publish('{}/{}/$name'.format(base_topic, device_id), device_id, 1, True)
  146. mqtt_client.publish('{}/{}/$fw/version'.format(base_topic, device_id), flora['firmware'], 1, True)
  147. nodes_list = ','.join([flora_name for [flora_name, flora] in flores.items()])
  148. mqtt_client.publish('{}/{}/$nodes'.format(base_topic, device_id), nodes_list, 1, True)
  149. for [flora_name, flora] in flores.items():
  150. topic_path = '{}/{}/{}'.format(base_topic, device_id, flora_name)
  151. mqtt_client.publish('{}/$name'.format(topic_path), flora['pretty'], 1, True)
  152. mqtt_client.publish('{}/$type'.format(topic_path), 'miflora', 1, True)
  153. mqtt_client.publish('{}/$properties'.format(topic_path), 'battery,conductivity,light,moisture,temperature', 1, True)
  154. mqtt_client.publish('{}/battery/$settable'.format(topic_path), 'false', 1, True)
  155. mqtt_client.publish('{}/battery/$unit'.format(topic_path), 'percent', 1, True)
  156. mqtt_client.publish('{}/battery/$datatype'.format(topic_path), 'int', 1, True)
  157. mqtt_client.publish('{}/battery/$range'.format(topic_path), '0:100', 1, True)
  158. mqtt_client.publish('{}/conductivity/$settable'.format(topic_path), 'false', 1, True)
  159. mqtt_client.publish('{}/conductivity/$unit'.format(topic_path), 'µS/cm', 1, True)
  160. mqtt_client.publish('{}/conductivity/$datatype'.format(topic_path), 'int', 1, True)
  161. mqtt_client.publish('{}/conductivity/$range'.format(topic_path), '0:*', 1, True)
  162. mqtt_client.publish('{}/light/$settable'.format(topic_path), 'false', 1, True)
  163. mqtt_client.publish('{}/light/$unit'.format(topic_path), 'lux', 1, True)
  164. mqtt_client.publish('{}/light/$datatype'.format(topic_path), 'int', 1, True)
  165. mqtt_client.publish('{}/light/$range'.format(topic_path), '0:50000', 1, True)
  166. mqtt_client.publish('{}/moisture/$settable'.format(topic_path), 'false', 1, True)
  167. mqtt_client.publish('{}/moisture/$unit'.format(topic_path), 'percent', 1, True)
  168. mqtt_client.publish('{}/moisture/$datatype'.format(topic_path), 'int', 1, True)
  169. mqtt_client.publish('{}/moisture/$range'.format(topic_path), '0:100', 1, True)
  170. mqtt_client.publish('{}/temperature/$settable'.format(topic_path), 'false', 1, True)
  171. mqtt_client.publish('{}/temperature/$unit'.format(topic_path), '°C', 1, True)
  172. mqtt_client.publish('{}/temperature/$datatype'.format(topic_path), 'float', 1, True)
  173. mqtt_client.publish('{}/temperature/$range'.format(topic_path), '*', 1, True)
  174. sleep(0.5) # some slack for the publish roundtrip and callback function
  175. print()
  176. sd_notifier.notify('STATUS=Initialization complete, starting MQTT publish loop')
  177. #flores_to_openhab_items(flores)
  178. # Sensor data retrieval and publication
  179. while True:
  180. for [flora_name, flora] in flores.items():
  181. data = dict()
  182. retries = 3
  183. while retries > 0 and not flora['poller']._cache:
  184. try:
  185. flora['poller'].fill_cache()
  186. flora['poller'].parameter_value(MI_LIGHT)
  187. except IOError:
  188. print('Failed to retrieve data from Mi Flora Sensor "{}" ({}). Retrying ...'.format(flora['pretty'], flora['mac']), file=sys.stderr)
  189. sd_notifier.notify('STATUS=Failed to retrieve data from Mi Flora Sensor "{}" ({}). Retrying ...'.format(flora['pretty'], flora['mac']))
  190. retries = retries - 1
  191. if not flora['poller']._cache:
  192. continue
  193. for param in parameters:
  194. data[param] = flora['poller'].parameter_value(param)
  195. timestamp = strftime('%Y-%m-%d %H:%M:%S', localtime())
  196. if reporting_mode == 'mqtt-json':
  197. print('[{}] Attempting to publishing to MQTT topic "{}/{}" ...\nData: {}'.format(timestamp, base_topic, flora['pretty'], json.dumps(data)))
  198. mqtt_client.publish('{}/{}'.format(base_topic, flora_name), json.dumps(data))
  199. sleep(0.5) # some slack for the publish roundtrip and callback function
  200. print()
  201. elif reporting_mode == 'mqtt-homie':
  202. print('[{}] Attempting to publishing data for Mi Flora "{}" ...\nData: {}'.format(timestamp, flora['pretty'], str(data)))
  203. for [param, value] in data.items():
  204. mqtt_client.publish('{}/{}/{}/{}'.format(base_topic, device_id, flora_name, param), value, 1, False)
  205. sleep(0.5) # some slack for the publish roundtrip and callback function
  206. print()
  207. elif reporting_mode == 'json':
  208. data['timestamp'] = timestamp
  209. data['name'] = flora_name
  210. data['pretty_name'] = flora['pretty']
  211. data['mac'] = flora['mac']
  212. data['firmware'] = flora['firmware']
  213. print('Data:', json.dumps(data))
  214. else:
  215. raise NameError('Unexpected reporting_mode.')
  216. sd_notifier.notify('STATUS={} - Status messages for all sensors published'.format(strftime('%Y-%m-%d %H:%M:%S', localtime())))
  217. if daemon_enabled:
  218. print('Sleeping ({} seconds) ...'.format(sleep_period))
  219. sleep(sleep_period)
  220. print()
  221. else:
  222. print('Execution finished in non-daemon-mode.')
  223. sd_notifier.notify('STATUS=Execution finished in non-daemon-mode')
  224. if reporting_mode == 'mqtt-json':
  225. mqtt_client.disconnect()
  226. break