This is a fork of the mycroft-homeassistant skill in the mycroft-skills repo. This adds new functionality, several fixes, and a much improved UX with more natural utternces and vocab.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

474 lines
17 KiB

  1. from adapt.intent import IntentBuilder
  2. from mycroft.skills.core import FallbackSkill, intent_handler
  3. from mycroft.util.log import getLogger
  4. from mycroft.util.format import nice_number
  5. from mycroft import MycroftSkill, intent_file_handler
  6. from os.path import dirname, join
  7. from requests.exceptions import (
  8. RequestException,
  9. Timeout,
  10. InvalidURL,
  11. URLRequired,
  12. SSLError,
  13. HTTPError)
  14. from requests.packages.urllib3.exceptions import MaxRetryError
  15. from .ha_client import HomeAssistantClient
  16. __author__ = 'robconnolly, btotharye, nielstron'
  17. LOGGER = getLogger(__name__)
  18. # Timeout time for HA requests
  19. TIMEOUT = 10
  20. class HomeAssistantSkill(FallbackSkill):
  21. def __init__(self):
  22. MycroftSkill.__init__(self)
  23. super().__init__(name="HomeAssistantSkill")
  24. self.ha = None
  25. self.enable_fallback = False
  26. def _setup(self, force=False):
  27. if not self.settings:
  28. LOGGER.error("settings is not set")
  29. return
  30. if not self.settings.get('host'):
  31. LOGGER.error("host setting is not set")
  32. return
  33. if not len(self.settings.get('host')):
  34. LOGGER.error("host setting is empty")
  35. return
  36. if (force or self.ha is None):
  37. portnumber = self.settings.get('portnum')
  38. try:
  39. portnumber = int(portnumber)
  40. except TypeError:
  41. LOGGER.error("type error , using default post 8123")
  42. portnumber = 8123
  43. except ValueError:
  44. LOGGER.error("value error, using port 0")
  45. # String might be some rubbish (like '')
  46. portnumber = 0
  47. self.ha = HomeAssistantClient(
  48. self.settings.get('host'),
  49. self.settings.get('token'),
  50. portnumber,
  51. self.settings.get('ssl'),
  52. self.settings.get('verify')
  53. )
  54. if self.ha.connected():
  55. # Check if conversation component is loaded at HA-server
  56. # and activate fallback accordingly (ha-server/api/components)
  57. # TODO: enable other tools like dialogflow
  58. conversation_activated = self.ha.find_component(
  59. 'conversation'
  60. )
  61. if conversation_activated:
  62. self.enable_fallback = \
  63. self.settings.get('enable_fallback') == 'true'
  64. def _force_setup(self):
  65. LOGGER.debug('Force setup')
  66. self._setup(True)
  67. def initialize(self):
  68. self.language = self.config_core.get('lang')
  69. self.register_intent_file(
  70. 'switchOn.device.intent',
  71. self.handle_switchOn_intent
  72. )
  73. self.register_intent_file(
  74. 'switchOff.device.intent',
  75. self.handle_switchOff_intent
  76. )
  77. self.register_intent_file(
  78. 'set.climate.intent',
  79. self.handle_set_thermostat_intent
  80. )
  81. self.register_intent_file(
  82. 'set.light.brightness.intent',
  83. self.handle_light_set_intent
  84. )
  85. self.register_intent_file(
  86. 'track.device.intent',
  87. self.handle_tracker_intent
  88. )
  89. self.register_intent_file(
  90. 'trigger.automation.intent',
  91. self.handle_automation_intent
  92. )
  93. self.register_intent_file(
  94. 'status.sensor.intent',
  95. self.handle_sensor_intent
  96. )
  97. self.register_intent_file(
  98. 'set.light.color.intent',
  99. self.handle_light_color_intent
  100. )
  101. self.register_intent_file(
  102. 'media.player.pause.intent',
  103. self.handle_media_pause
  104. )
  105. self.register_intent_file(
  106. 'media.player.stop.intent',
  107. self.handle_media_stop
  108. )
  109. self.register_intent_file(
  110. 'media.player.play.intent',
  111. self.handle_media_play
  112. )
  113. # Needs higher priority than general fallback skills
  114. self.register_fallback(self.handle_fallback, 2)
  115. # Check and then monitor for credential changes
  116. self.settings.set_changed_callback(self.on_websettings_changed)
  117. self._setup()
  118. def on_websettings_changed(self):
  119. # Force a setting refresh after the websettings changed
  120. # Otherwise new settings will not be regarded
  121. self._force_setup()
  122. # Try to find an entity on the HAServer
  123. # Creates dialogs for errors and speaks them
  124. # Returns None if nothing was found
  125. # Else returns entity that was found
  126. def _find_entity(self, entity, domains):
  127. self._setup()
  128. if self.ha is None:
  129. self.speak_dialog('homeassistant.error.setup')
  130. return False
  131. # TODO if entity is 'all', 'any' or 'every' turn on
  132. # every single entity not the whole group
  133. ha_entity = self._handle_client_exception(self.ha.find_entity,
  134. entity, domains)
  135. if ha_entity is None:
  136. self.speak_dialog('homeassistant.device.unknown', data={
  137. "dev_name": entity})
  138. return ha_entity
  139. # Calls passed method and catches often occurring exceptions
  140. def _handle_client_exception(self, callback, *args, **kwargs):
  141. try:
  142. return callback(*args, **kwargs)
  143. except Timeout:
  144. self.speak_dialog('homeassistant.error.offline')
  145. except (InvalidURL, URLRequired, MaxRetryError) as e:
  146. self.speak_dialog('homeassistant.error.invalidurl', data={
  147. 'url': e.request.url})
  148. except SSLError:
  149. self.speak_dialog('homeassistant.error.ssl')
  150. except HTTPError as e:
  151. # check if due to wrong password
  152. if e.response.status_code == 401:
  153. self.speak_dialog('homeassistant.error.wrong_password')
  154. else:
  155. self.speak_dialog('homeassistant.error.http', data={
  156. 'code': e.response.status_code,
  157. 'reason': e.response.reason})
  158. except (ConnectionError, RequestException) as exception:
  159. # TODO find a nice member of any exception to output
  160. self.speak_dialog('homeassistant.error', data={
  161. 'url': exception.request.url})
  162. return False
  163. def handle_switch_intent(self, entity, action):
  164. LOGGER.debug("Starting Switch Intent")
  165. LOGGER.debug("Entity: %s" % entity)
  166. LOGGER.debug("Action: %s" % action)
  167. ha_entity = self._find_entity(
  168. entity,
  169. [
  170. 'group',
  171. 'light',
  172. 'fan',
  173. 'switch',
  174. 'scene',
  175. 'input_boolean',
  176. 'climate'
  177. ]
  178. )
  179. if not ha_entity:
  180. return
  181. LOGGER.debug("Entity State: %s" % ha_entity['state'])
  182. ha_data = {'entity_id': ha_entity['id']}
  183. # IDEA: set context for 'turn it off' again or similar
  184. # self.set_context('Entity', ha_entity['dev_name'])
  185. if action == "toggle":
  186. self.ha.execute_service("homeassistant", "toggle",
  187. ha_data)
  188. if(ha_entity['state'] == 'off'):
  189. action = 'on'
  190. else:
  191. action = 'off'
  192. self.speak_dialog('homeassistant.device.%s' % action,
  193. data=ha_entity)
  194. elif action in ["on", "off"]:
  195. self.speak_dialog('homeassistant.device.%s' % action,
  196. data=ha_entity)
  197. self.ha.execute_service("homeassistant", "turn_%s" % action,
  198. ha_data)
  199. else:
  200. self.speak_dialog('homeassistant.error.sorry')
  201. return
  202. def handle_switchOn_intent(self, message):
  203. LOGGER.debug("Starting switchOn intent")
  204. entity = message.data["entity"]
  205. self.handle_switch_intent(entity, "on")
  206. def handle_switchOff_intent(self, message):
  207. LOGGER.debug("Starting switchOff intent")
  208. entity = message.data["entity"]
  209. self.handle_switch_intent(entity, "off")
  210. def handle_media_player(self, entity, action):
  211. LOGGER.debug("Entity: %s" % entity)
  212. LOGGER.debug("Action: %s" % action)
  213. ha_entity = self._find_entity(entity, ['media_player'])
  214. if not ha_entity:
  215. return
  216. ha_data = {'entity_id': ha_entity['id']}
  217. dg_data = {'entity': ha_entity['dev_name'], 'action': action}
  218. self.ha.execute_service("media_player", "media_%s" % action, ha_data)
  219. self.speak_dialog("homeassistant.media.player.%s" % action, dg_data)
  220. def handle_media_stop(self, message):
  221. entity = message.data["entity"]
  222. self.handle_media_player(entity, "stop")
  223. def handle_media_play(self, message):
  224. entity = message.data["entity"]
  225. self.handle_media_player(entity, "play")
  226. def handle_media_pause(self, message):
  227. entity = message.data["entity"]
  228. self.handle_media_player(entity, "pause")
  229. def handle_light_color_intent(self, message):
  230. entity = message.data["entity"]
  231. color = message.data["color"]
  232. LOGGER.debug("Entity: %s" % entity)
  233. LOGGER.debug("Color: %s" % color)
  234. ha_entity = self._find_entity(entity, ['group', 'light'])
  235. if not ha_entity:
  236. return
  237. ha_data = {'entity_id': ha_entity['id']}
  238. dg_data = {'dev_name': ha_entity['dev_name'], 'color': color}
  239. ha_data['color_name'] = color
  240. self.ha.execute_service("light", "turn_on", ha_data)
  241. self.speak_dialog('homeassistant.light.color', dg_data)
  242. def handle_light_set_intent(self, message):
  243. entity = message.data["entity"]
  244. try:
  245. brightness_req = float(message.data["brightnessvalue"])
  246. if brightness_req > 100 or brightness_req < 0:
  247. self.speak_dialog('homeassistant.brightness.badreq')
  248. except KeyError:
  249. brightness_req = 10.0
  250. brightness_value = int(brightness_req / 100 * 255)
  251. brightness_percentage = int(brightness_req)
  252. LOGGER.debug("Entity: %s" % entity)
  253. LOGGER.debug("Brightness Value: %s" % brightness_value)
  254. LOGGER.debug("Brightness Percent: %s" % brightness_percentage)
  255. ha_entity = self._find_entity(entity, ['group', 'light'])
  256. if not ha_entity:
  257. return
  258. ha_data = {'entity_id': ha_entity['id']}
  259. # IDEA: set context for 'turn it off again' or similar
  260. # self.set_context('Entity', ha_entity['dev_name'])
  261. ha_data['brightness'] = brightness_value
  262. dg_data = {'brightness_percentage': brightness_percentage}
  263. dg_data['dev_name'] = ha_entity['dev_name']
  264. self.ha.execute_service("light", "turn_on", ha_data)
  265. self.speak_dialog('homeassistant.brightness.dimmed',
  266. data=dg_data)
  267. return
  268. def handle_automation_intent(self, message):
  269. entity = message.data["entity"]
  270. LOGGER.debug("Entity: %s" % entity)
  271. ha_entity = self._find_entity(
  272. entity,
  273. ['automation', 'scene', 'script']
  274. )
  275. if not ha_entity:
  276. return
  277. ha_data = {'entity_id': ha_entity['id']}
  278. # IDEA: set context for 'turn it off again' or similar
  279. # self.set_context('Entity', ha_entity['dev_name'])
  280. LOGGER.debug("Triggered automation/scene/script: {}".format(ha_data))
  281. if "automation" in ha_entity['id']:
  282. self.ha.execute_service('automation', 'trigger', ha_data)
  283. self.speak_dialog('homeassistant.automation.trigger',
  284. data={"dev_name": ha_entity['dev_name']})
  285. elif "script" in ha_entity['id']:
  286. self.speak_dialog('homeassistant.automation.trigger',
  287. data={"dev_name": ha_entity['dev_name']})
  288. self.ha.execute_service("homeassistant", "turn_on",
  289. data=ha_data)
  290. elif "scene" in ha_entity['id']:
  291. self.speak_dialog('homeassistant.device.on',
  292. data=ha_entity)
  293. self.ha.execute_service("homeassistant", "turn_on",
  294. data=ha_data)
  295. def handle_sensor_intent(self, message):
  296. entity = message.data["entity"]
  297. LOGGER.debug("Entity: %s" % entity)
  298. ha_entity = self._find_entity(entity, ['sensor', 'switch'])
  299. if not ha_entity:
  300. return
  301. entity = ha_entity['id']
  302. # IDEA: set context for 'read it out again' or similar
  303. # self.set_context('Entity', ha_entity['dev_name'])
  304. unit_measurement = self.ha.find_entity_attr(entity)
  305. sensor_unit = unit_measurement.get('unit_measure') or ''
  306. sensor_name = unit_measurement['name']
  307. sensor_state = unit_measurement['state']
  308. # extract unit for correct pronounciation
  309. # this is fully optional
  310. try:
  311. from quantulum import parser
  312. quantulumImport = True
  313. except ImportError:
  314. quantulumImport = False
  315. if quantulumImport and unit_measurement != '':
  316. quantity = parser.parse((u'{} is {} {}'.format(
  317. sensor_name, sensor_state, sensor_unit)))
  318. if len(quantity) > 0:
  319. quantity = quantity[0]
  320. if (quantity.unit.name != "dimensionless" and
  321. quantity.uncertainty <= 0.5):
  322. sensor_unit = quantity.unit.name
  323. sensor_state = quantity.value
  324. try:
  325. value = float(sensor_state)
  326. sensor_state = nice_number(value, lang=self.language)
  327. except ValueError:
  328. pass
  329. self.speak_dialog('homeassistant.sensor', data={
  330. "dev_name": sensor_name,
  331. "value": sensor_state,
  332. "unit": sensor_unit})
  333. # IDEA: Add some context if the person wants to look the unit up
  334. # Maybe also change to name
  335. # if one wants to look up "outside temperature"
  336. # self.set_context("SubjectOfInterest", sensor_unit)
  337. # In progress, still testing.
  338. # Device location works.
  339. # Proximity might be an issue
  340. # - overlapping command for directions modules
  341. # - (e.g. "How far is x from y?")
  342. def handle_tracker_intent(self, message):
  343. entity = message.data["entity"]
  344. LOGGER.debug("Entity: %s" % entity)
  345. ha_entity = self._find_entity(entity, ['device_tracker'])
  346. if not ha_entity:
  347. return
  348. # IDEA: set context for 'locate it again' or similar
  349. # self.set_context('Entity', ha_entity['dev_name'])
  350. entity = ha_entity['id']
  351. dev_name = ha_entity['dev_name']
  352. dev_location = ha_entity['state']
  353. self.speak_dialog('homeassistant.tracker.found',
  354. data={'dev_name': dev_name,
  355. 'location': dev_location})
  356. def handle_set_thermostat_intent(self, message):
  357. entity = message.data["entity"]
  358. LOGGER.debug("Entity: %s" % entity)
  359. LOGGER.debug("This is the message data: %s" % message.data)
  360. temperature = message.data["temp"]
  361. LOGGER.debug("Temperature: %s" % temperature)
  362. ha_entity = self._find_entity(entity, ['climate'])
  363. if not ha_entity:
  364. return
  365. climate_data = {
  366. 'entity_id': ha_entity['id'],
  367. 'temperature': temperature
  368. }
  369. climate_attr = self.ha.find_entity_attr(ha_entity['id'])
  370. self.ha.execute_service("climate", "set_temperature",
  371. data=climate_data)
  372. self.speak_dialog('homeassistant.set.thermostat',
  373. data={
  374. "dev_name": climate_attr['name'],
  375. "value": temperature,
  376. "unit": climate_attr['unit_measure']})
  377. def handle_fallback(self, message):
  378. if not self.enable_fallback:
  379. return False
  380. self._setup()
  381. if self.ha is None:
  382. self.speak_dialog('homeassistant.error.setup')
  383. return False
  384. # pass message to HA-server
  385. response = self._handle_client_exception(
  386. self.ha.engage_conversation,
  387. message.data.get('utterance'))
  388. if not response:
  389. return False
  390. # default non-parsing answer: "Sorry, I didn't understand that"
  391. answer = response.get('speech')
  392. if not answer or answer == "Sorry, I didn't understand that":
  393. return False
  394. asked_question = False
  395. # TODO: maybe enable conversation here if server asks sth like
  396. # "In which room?" => answer should be directly passed to this skill
  397. if answer.endswith("?"):
  398. asked_question = True
  399. self.speak(answer, expect_response=asked_question)
  400. return True
  401. def shutdown(self):
  402. self.remove_fallback(self.handle_fallback)
  403. super(HomeAssistantSkill, self).shutdown()
  404. def stop(self):
  405. pass
  406. def create_skill():
  407. return HomeAssistantSkill()