miflora-mqtt-daemon.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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.MAGENTA + '[{}] '.format(timestamp) + Fore.RED + '{}'.format(text) + Fore.RESET, file=sys.stderr)
  34. elif warning:
  35. print(Fore.MAGENTA + '[{}] '.format(timestamp) + Fore.YELLOW + '{}'.format(text) + Fore.RESET)
  36. else:
  37. print(Fore.MAGENTA + '[{}] '.format(timestamp) + Fore.RESET + '{}'.format(text))
  38. if sd_notify:
  39. sd_notifier.notify('STATUS={} - {}.'.format(timestamp, text))
  40. # Eclipse Paho callbacks - http://www.eclipse.org/paho/clients/python/docs/#callbacks
  41. def on_connect(client, userdata, flags, rc):
  42. if rc == 0:
  43. print_line('MQTT connection established', console=True, sd_notify=True)
  44. print()
  45. else:
  46. print_line('Connection error with result code {} - {}'.format(str(rc), mqtt.connack_string(rc)), error=True)
  47. #kill main thread
  48. os._exit(1)
  49. def on_publish(client, userdata, mid):
  50. #print_line('Data successfully published.')
  51. pass
  52. def flores_to_openhab_items(flores, reporting_mode):
  53. items = list()
  54. items.append('// miflora.items - Generated by miflora-mqtt-daemon.')
  55. items.append('// Adapt to your needs! Things you probably want to modify:')
  56. items.append('// Room group names, icons,')
  57. items.append('// "gAll", "broker", "UnknownRoom"')
  58. items.append('')
  59. items.append('// Mi Flora specific groups')
  60. for param, param_properties in parameters.items():
  61. items.append('Group g{} "Mi Flora {} elements" (gAll)'.format(param.capitalize(), param_properties['pretty']))
  62. if reporting_mode == 'mqtt-json':
  63. for [flora_name, flora] in flores.items():
  64. location = flora['location'] if flora['location'] else 'UnknownRoom'
  65. items.append('\n// Mi Flora "{}" ({})'.format(flora['pretty'], flora['mac']))
  66. for [param, param_properties] in parameters.items():
  67. basic = 'Number {}_{}_{}'.format(location, flora_name.capitalize(), param.capitalize())
  68. label = '"{} {} {} [{} {}]"'.format(location, flora['pretty'], param_properties['pretty'], param_properties['typeformat'], param_properties['unit'].replace('%', '%%'))
  69. details = '<text> (g{}, g{})'.format(location, param.capitalize())
  70. channel = '{{mqtt="<[broker:{}/{}:state:JSONPATH($.{})]"}}'.format(base_topic, flora_name, param)
  71. items.append(' '.join([basic, label, details, channel]))
  72. items.append('')
  73. print('\n'.join(items))
  74. #elif reporting_mode == 'mqtt-homie':
  75. else:
  76. print('Sorry, given reporting_mode not supported for the export to openHAB items')
  77. # Load configuration file
  78. config = ConfigParser(delimiters=('=', ))
  79. config.optionxform = str
  80. config.read([os.path.join(sys.path[0], 'config.ini'), os.path.join(sys.path[0], 'config.local.ini')])
  81. reporting_mode = config['General'].get('reporting_method', 'mqtt-json')
  82. daemon_enabled = config['Daemon'].getboolean('enabled', True)
  83. base_topic = config['MQTT'].get('base_topic', 'homie' if reporting_mode == 'mqtt-homie' else 'miflora').lower()
  84. device_id = config['MQTT'].get('homie_device_id', 'miflora-mqtt-daemon').lower()
  85. sleep_period = config['Daemon'].getint('period', 300)
  86. #miflora_cache_timeout = config['MiFlora'].getint('cache_timeout', 600)
  87. miflora_cache_timeout = sleep_period - 1
  88. # Check configuration
  89. if not reporting_mode in ['mqtt-json', 'mqtt-homie', 'json']:
  90. print_line('Configuration parameter reporting_mode set to an invalid value', error=True, sd_notify=True)
  91. sys.exit(1)
  92. if not config['Sensors']:
  93. print_line('No sensors found in configuration file "config.ini"', error=True, sd_notify=True)
  94. sys.exit(1)
  95. print_line('Configuration accepted', console=False, sd_notify=True)
  96. # MQTT connection
  97. if reporting_mode in ['mqtt-json', 'mqtt-homie']:
  98. print_line('Connecting to MQTT broker ...')
  99. mqtt_client = mqtt.Client()
  100. mqtt_client.on_connect = on_connect
  101. mqtt_client.on_publish = on_publish
  102. if reporting_mode == 'mqtt-json':
  103. mqtt_client.will_set('{}/$announce'.format(base_topic), payload='{}', retain=True)
  104. elif reporting_mode == 'mqtt-homie':
  105. mqtt_client.will_set('{}/{}/$online'.format(base_topic, device_id), payload='false', retain=True)
  106. if config['MQTT'].get('username'):
  107. mqtt_client.username_pw_set(config['MQTT'].get('username'), config['MQTT'].get('password', None))
  108. try:
  109. mqtt_client.connect(config['MQTT'].get('hostname', 'localhost'),
  110. port=config['MQTT'].getint('port', 1883),
  111. keepalive=config['MQTT'].getint('keepalive', 60))
  112. except:
  113. print_line('MQTT connection error. Please check your settings in the configuration file "config.ini"', error=True, sd_notify=True)
  114. sys.exit(1)
  115. else:
  116. mqtt_client.loop_start()
  117. sleep(1.0) # some slack to establish the connection
  118. sd_notifier.notify('READY=1')
  119. # Initialize Mi Flora sensors
  120. flores = dict()
  121. for [name, mac] in config['Sensors'].items():
  122. if not re.match("C4:7C:8D:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}", mac):
  123. print_line('The MAC address "{}" seems to be in the wrong format. Please check your configuration'.format(mac), error=True, sd_notify=True)
  124. sys.exit(1)
  125. location = ''
  126. if '@' in name:
  127. name, location = name.split("@")
  128. location = location.replace(' ', '-')
  129. name_pretty = name
  130. name_clean = name.lower()
  131. for o, n in [[' ', '-'], ['ä', 'ae'], ['ö', 'oe'], ['ü', 'ue'], ['ß', 'ss']]:
  132. name_clean = name_clean.replace(o, n)
  133. name_clean = unidecode(name_clean)
  134. flora = dict()
  135. print('Adding sensor to device list and testing connection ...')
  136. print('Name: "{}"'.format(name_pretty))
  137. #print_line('Attempting initial connection to Mi Flora sensor "{}" ({})'.format(name_pretty, mac), console=False, sd_notify=True)
  138. flora_poller = MiFloraPoller(mac=mac, cache_timeout=miflora_cache_timeout, retries=3)
  139. flora['poller'] = flora_poller
  140. flora['pretty'] = name_pretty
  141. flora['mac'] = flora_poller._mac
  142. flora['refresh'] = sleep_period
  143. flora['location'] = location
  144. try:
  145. flora_poller.fill_cache()
  146. flora_poller.parameter_value(MI_LIGHT)
  147. flora['firmware'] = flora_poller.firmware_version()
  148. except IOError:
  149. print_line('Initial connection to Mi Flora sensor "{}" ({}) failed. Sensor will not be used'.format(name_pretty, mac), error=True, sd_notify=True)
  150. print()
  151. continue
  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']}
  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. retries = 3
  213. flora['poller']._cache = None
  214. print_line('Retrieving data from sensor "{}" ...'.format(flora['pretty']))
  215. while retries > 0 and not flora['poller']._cache:
  216. try:
  217. flora['poller'].fill_cache()
  218. flora['poller'].parameter_value(MI_LIGHT)
  219. except IOError:
  220. retries = retries - 1
  221. if retries > 0:
  222. print_line('Retrying ...', warning = True)
  223. if not flora['poller']._cache:
  224. print_line('Failed to retrieve data from Mi Flora sensor "{}" ({})'.format(flora['pretty'], flora['mac']), error = True, sd_notify = True)
  225. continue
  226. for param,_ in parameters.items():
  227. data[param] = flora['poller'].parameter_value(param)
  228. print_line('Result: {}'.format(json.dumps(data)))
  229. if reporting_mode == 'mqtt-json':
  230. print_line('Publishing to MQTT topic "{}/{}"'.format(base_topic, flora_name))
  231. mqtt_client.publish('{}/{}'.format(base_topic, flora_name), json.dumps(data))
  232. sleep(0.5) # some slack for the publish roundtrip and callback function
  233. elif reporting_mode == 'mqtt-homie':
  234. print_line('Publishing data to MQTT base topic "{}/{}/{}"'.format(base_topic, device_id, flora_name))
  235. for [param, value] in data.items():
  236. mqtt_client.publish('{}/{}/{}/{}'.format(base_topic, device_id, flora_name, param), value, 1, False)
  237. sleep(0.5) # some slack for the publish roundtrip and callback function
  238. elif reporting_mode == 'json':
  239. data['timestamp'] = strftime('%Y-%m-%d %H:%M:%S', localtime())
  240. data['name'] = flora_name
  241. data['pretty_name'] = flora['pretty']
  242. data['mac'] = flora['mac']
  243. data['firmware'] = flora['firmware']
  244. print('Data for "{}": {}'.format(flora_name, json.dumps(data)))
  245. else:
  246. raise NameError('Unexpected reporting_mode.')
  247. print()
  248. print_line('Status messages published', console=False, sd_notify=True)
  249. if daemon_enabled:
  250. print('Sleeping ({} seconds) ...'.format(sleep_period))
  251. sleep(sleep_period)
  252. print()
  253. else:
  254. print_line('Execution finished in non-daemon-mode', sd_notify=True)
  255. if reporting_mode == 'mqtt-json':
  256. mqtt_client.disconnect()
  257. break