miflora-mqtt-daemon.py 12 KB

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