miflora-mqtt-daemon.py 16 KB

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