media_player.py 7.8 KB

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