media_player.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. """PJLink2 media_player platform."""
  2. from __future__ import annotations
  3. from collections.abc import Callable
  4. from datetime import timedelta
  5. import logging
  6. from typing import Any
  7. from aiopjlink import (
  8. PJLink,
  9. PJLinkException,
  10. PJLinkProjectorError,
  11. Power,
  12. Sources,
  13. Lamp,
  14. Information,
  15. )
  16. from homeassistant import config_entries, core
  17. from homeassistant.components.media_player import (
  18. MediaPlayerEntity,
  19. MediaPlayerEntityFeature,
  20. MediaPlayerState,
  21. PLATFORM_SCHEMA,
  22. )
  23. from homeassistant.const import (
  24. CONF_HOST,
  25. CONF_PORT,
  26. CONF_NAME,
  27. CONF_PASSWORD,
  28. CONF_TIMEOUT,
  29. )
  30. from homeassistant.core import HomeAssistant as HomeAssistantType
  31. import homeassistant.helpers.config_validation as cv
  32. from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
  33. import voluptuous as vol
  34. from .const import (
  35. DOMAIN,
  36. CONF_SOURCES,
  37. CONF_ENCODING,
  38. DEFAULT_ENCODING,
  39. DEFAULT_PORT,
  40. DEFAULT_TIMEOUT,
  41. ATTR_PRODUCT_NAME,
  42. ATTR_MANUFACTURER_NAME,
  43. ATTR_PROJECTOR_NAME,
  44. ATTR_RESOLUTION_X,
  45. ATTR_RESOLUTION_Y,
  46. ATTR_LAMP_HOURS,
  47. ProjectorState,
  48. )
  49. _LOGGER = logging.getLogger(__name__)
  50. SCAN_INTERVAL = timedelta(seconds=3)
  51. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
  52. {
  53. vol.Required(CONF_HOST): cv.string,
  54. vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
  55. vol.Optional(CONF_NAME): cv.string,
  56. vol.Optional(CONF_SOURCES): vol.Schema({cv.string: cv.string}),
  57. vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
  58. vol.Optional(CONF_PASSWORD): cv.string,
  59. vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_float,
  60. }
  61. )
  62. async def async_setup_platform(
  63. hass: HomeAssistantType,
  64. config: ConfigType,
  65. async_add_entities: Callable,
  66. discovery_info: DiscoveryInfoType | None = None,
  67. ) -> None:
  68. host = config.get(CONF_HOST)
  69. port = config.get(CONF_PORT)
  70. password = config.get(CONF_PASSWORD)
  71. timeout = config.get(CONF_TIMEOUT)
  72. name = config.get(CONF_NAME)
  73. sources = config.get(CONF_SOURCES)
  74. pjl = PJLink(host, port, password, timeout)
  75. devices = [PJLink2MediaPlayer(pjl, name, sources)]
  76. async_add_entities(devices, update_before_add=False)
  77. class PJLink2MediaPlayer(MediaPlayerEntity):
  78. _attr_supported_features = (
  79. MediaPlayerEntityFeature.TURN_ON
  80. | MediaPlayerEntityFeature.TURN_OFF
  81. | MediaPlayerEntityFeature.SELECT_SOURCE
  82. )
  83. def __init__(self, pjl, name, sources):
  84. super().__init__()
  85. self._projector = pjl
  86. self.attrs: dict[str, Any] = {}
  87. self._name = name
  88. self._state = MediaPlayerState.OFF
  89. # The Fix: Decoupling TCP socket state from HA's availability state
  90. self._socket_open = False
  91. self._is_available = False
  92. self._connectionErrorLogged = False
  93. self._current_source = None
  94. if sources:
  95. self._source_mapping = sources
  96. self._source_list = list(self._source_mapping.values())
  97. self._reverse_mapping = {
  98. v: k for k, v in self._source_mapping.items()
  99. }
  100. self._dynamic_sources = False
  101. else:
  102. self._source_mapping = {}
  103. self._source_list = []
  104. self._reverse_mapping = {}
  105. self._dynamic_sources = True
  106. async def async_will_remove_from_hass(self) -> None:
  107. await super().async_will_remove_from_hass()
  108. if self._socket_open:
  109. try:
  110. await self._projector.__aexit__(0, 0, 0)
  111. except Exception:
  112. pass
  113. @property
  114. def name(self) -> str:
  115. return self._name
  116. @property
  117. def unique_id(self) -> str:
  118. return self._projector._address
  119. @property
  120. def available(self) -> bool:
  121. return self._is_available
  122. @property
  123. def state(self) -> MediaPlayerState:
  124. return self._state
  125. @property
  126. def source(self) -> str | None:
  127. return self._current_source
  128. @property
  129. def source_list(self) -> list[str]:
  130. return self._source_list
  131. @property
  132. def extra_state_attributes(self) -> dict[str, Any]:
  133. return self.attrs
  134. async def async_turn_on(self) -> None:
  135. await Power(self._projector).set(Power.ON)
  136. self._state = MediaPlayerState.ON
  137. async def async_turn_off(self) -> None:
  138. await Power(self._projector).set(Power.OFF)
  139. self._state = MediaPlayerState.OFF
  140. async def async_select_source(self, source: str) -> None:
  141. raw_source = self._reverse_mapping.get(source, source)
  142. source_type = raw_source[0]
  143. source_index = raw_source[1]
  144. await Sources(self._projector).set(source_type, source_index)
  145. self._current_source = source
  146. async def async_update(self) -> None:
  147. try:
  148. if not self._socket_open:
  149. await self._projector.__aenter__()
  150. self._socket_open = True
  151. self._is_available = True
  152. info = await Information(self._projector).table()
  153. self.attrs[ATTR_PRODUCT_NAME] = info.get("product_name")
  154. self.attrs[ATTR_MANUFACTURER_NAME] = info.get(
  155. "manufacturer_name"
  156. )
  157. self.attrs[ATTR_PROJECTOR_NAME] = info.get("projector_name")
  158. if self._name is None:
  159. self._name = info.get("projector_name")
  160. pwr = await Power(self._projector).get()
  161. if pwr == Power.State.OFF:
  162. self._state = MediaPlayerState.OFF
  163. elif pwr == Power.State.ON:
  164. self._state = MediaPlayerState.ON
  165. elif pwr in (Power.State.COOLING, Power.State.WARMING):
  166. self._state = MediaPlayerState.ON
  167. if pwr == Power.ON:
  168. try:
  169. current = await Sources(self._projector).get()
  170. if isinstance(current, (tuple, list)):
  171. src_type = (
  172. current[0].value
  173. if hasattr(current[0], "value")
  174. else current[0]
  175. )
  176. src_index = current[1]
  177. raw_source = f"{src_type}{src_index}"
  178. else:
  179. raw_source = str(current)
  180. if (
  181. self._dynamic_sources
  182. and raw_source not in self._source_list
  183. ):
  184. self._source_list.append(raw_source)
  185. self._current_source = self._source_mapping.get(
  186. raw_source, raw_source
  187. )
  188. except Exception as e:
  189. if "ERR3" in repr(e) or "unavailable" in repr(e):
  190. raise e
  191. _LOGGER.debug("Ignored error getting source: %s", repr(e))
  192. try:
  193. self.attrs[ATTR_LAMP_HOURS] = await Lamp(
  194. self._projector
  195. ).hours()
  196. except Exception:
  197. pass
  198. try:
  199. res = await Sources(self._projector).resolution()
  200. self.attrs[ATTR_RESOLUTION_X] = res[0]
  201. self.attrs[ATTR_RESOLUTION_Y] = res[1]
  202. except Exception:
  203. self.attrs.pop(ATTR_RESOLUTION_X, None)
  204. self.attrs.pop(ATTR_RESOLUTION_Y, None)
  205. elif pwr == Power.State.OFF:
  206. self.attrs.pop(ATTR_RESOLUTION_X, None)
  207. self.attrs.pop(ATTR_RESOLUTION_Y, None)
  208. self._current_source = None
  209. self._connectionErrorLogged = False
  210. except Exception as err:
  211. err_str = repr(err)
  212. if self._socket_open:
  213. self._socket_open = False
  214. try:
  215. await self._projector.__aexit__(0, 0, 0)
  216. except Exception:
  217. pass
  218. if "ERR3" in err_str or "unavailable" in err_str:
  219. _LOGGER.debug(
  220. "Projector is busy switching inputs. Reconnecting next poll."
  221. )
  222. # Notice we do NOT set self._is_available = False here!
  223. # This keeps the attributes stable in Home Assistant.
  224. else:
  225. if not self._connectionErrorLogged:
  226. _LOGGER.error(
  227. "PJLink2 ERROR for %s: %s", self._name, err_str
  228. )
  229. self._connectionErrorLogged = True
  230. self._is_available = False
  231. self._state = MediaPlayerState.OFF