miflora-mqtt-daemon.py 13 KB

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