media_player.py 9.9 KB

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