media_player.py 10.0 KB

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