media_player.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  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. """Set up the media_player platform."""
  67. host = config.get(CONF_HOST)
  68. port = config.get(CONF_PORT)
  69. password = config.get(CONF_PASSWORD)
  70. timeout = config.get(CONF_TIMEOUT)
  71. name = config.get(CONF_NAME)
  72. pjl = PJLink(host, port, password, timeout)
  73. devices = [PJLink2MediaPlayer(pjl, name)]
  74. async_add_entities(devices, update_before_add=False)
  75. class PJLink2MediaPlayer(MediaPlayerEntity):
  76. """Representation of a PJLink2 media player."""
  77. _attr_supported_features = (
  78. MediaPlayerEntityFeature.TURN_ON
  79. | MediaPlayerEntityFeature.TURN_OFF
  80. | MediaPlayerEntityFeature.SELECT_SOURCE
  81. )
  82. def __init__(self, pjl, name):
  83. super().__init__()
  84. self._projector = pjl
  85. self.attrs: dict[str, Any] = {}
  86. self._name = name
  87. self._state = MediaPlayerState.OFF
  88. self._available = False
  89. self._connectionErrorLogged = False
  90. self._current_source = None
  91. # --- FRIENDLY NAMES MAPPING ---
  92. # Change these values to whatever you want them to be called in Home Assistant
  93. self._source_mapping = {
  94. "31": "HDMI 1",
  95. "32": "HDMI 2",
  96. "33": "HDMI 3",
  97. "11": "Computer 1",
  98. }
  99. self._reverse_mapping = {v: k for k, v in self._source_mapping.items()}
  100. self._source_list = list(self._source_mapping.values())
  101. async def async_will_remove_from_hass(self) -> None:
  102. """Close connection."""
  103. await super().async_will_remove_from_hass()
  104. if self._available:
  105. try:
  106. await self._projector.__aexit__(0, 0, 0)
  107. except Exception:
  108. pass
  109. @property
  110. def name(self) -> str:
  111. return self._name
  112. @property
  113. def unique_id(self) -> str:
  114. return self._projector._address
  115. @property
  116. def available(self) -> bool:
  117. return self._available
  118. @property
  119. def state(self) -> MediaPlayerState:
  120. return self._state
  121. @property
  122. def source(self) -> str | None:
  123. return self._current_source
  124. @property
  125. def source_list(self) -> list[str]:
  126. return self._source_list
  127. @property
  128. def extra_state_attributes(self) -> dict[str, Any]:
  129. return self.attrs
  130. async def async_turn_on(self) -> None:
  131. await Power(self._projector).set(Power.ON)
  132. self._state = MediaPlayerState.ON
  133. async def async_turn_off(self) -> None:
  134. await Power(self._projector).set(Power.OFF)
  135. self._state = MediaPlayerState.OFF
  136. async def async_select_source(self, source: str) -> None:
  137. """Select input source."""
  138. # Convert friendly name (e.g. "HDMI 1") back to raw PJLink code (e.g. "31")
  139. raw_source = self._reverse_mapping.get(source, source)
  140. source_type = raw_source[0]
  141. source_index = raw_source[1]
  142. await Sources(self._projector).set(source_type, source_index)
  143. self._current_source = source
  144. async def async_update(self) -> None:
  145. """Update data from projector."""
  146. try:
  147. if not self._available:
  148. await self._projector.__aenter__()
  149. self._available = True
  150. info = await Information(self._projector).table()
  151. self.attrs[ATTR_PRODUCT_NAME] = info.get("product_name")
  152. self.attrs[ATTR_MANUFACTURER_NAME] = info.get(
  153. "manufacturer_name"
  154. )
  155. self.attrs[ATTR_PROJECTOR_NAME] = info.get("projector_name")
  156. if self._name is None:
  157. self._name = info.get("projector_name")
  158. pwr = await Power(self._projector).get()
  159. if pwr == Power.State.OFF:
  160. self._state = MediaPlayerState.OFF
  161. elif pwr == Power.State.ON:
  162. self._state = MediaPlayerState.ON
  163. elif pwr in (Power.State.COOLING, Power.State.WARMING):
  164. self._state = MediaPlayerState.ON
  165. if pwr == Power.ON:
  166. # 1. Fetch current source
  167. try:
  168. current = await Sources(self._projector).get()
  169. if isinstance(current, (tuple, list)):
  170. raw_source = "".join(map(str, current))
  171. else:
  172. raw_source = str(current)
  173. # Apply friendly name mapping
  174. self._current_source = self._source_mapping.get(
  175. raw_source, raw_source
  176. )
  177. except Exception as e:
  178. # THE FIX: If the projector is busy (ERR3), bubble the error up so we force a disconnect
  179. if "ERR3" in repr(e) or "unavailable" in repr(e):
  180. raise e
  181. _LOGGER.debug("Ignored error getting source: %s", repr(e))
  182. # 2. Fetch Lamp Hours
  183. try:
  184. self.attrs[ATTR_LAMP_HOURS] = await Lamp(
  185. self._projector
  186. ).hours()
  187. except Exception:
  188. pass
  189. # 3. Fetch Resolution
  190. try:
  191. res = await Sources(self._projector).resolution()
  192. self.attrs[ATTR_RESOLUTION_X] = res[0]
  193. self.attrs[ATTR_RESOLUTION_Y] = res[1]
  194. except Exception:
  195. self.attrs.pop(ATTR_RESOLUTION_X, None)
  196. self.attrs.pop(ATTR_RESOLUTION_Y, None)
  197. else:
  198. self.attrs.pop(ATTR_RESOLUTION_X, None)
  199. self.attrs.pop(ATTR_RESOLUTION_Y, None)
  200. self._current_source = None
  201. self._connectionErrorLogged = False
  202. except Exception as err:
  203. err_str = repr(err)
  204. # THE FIX: Forcefully drop the connection on any error.
  205. # This prevents the projector from getting "stuck" on a stale socket.
  206. if self._available:
  207. self._available = False
  208. try:
  209. await self._projector.__aexit__(0, 0, 0)
  210. except Exception:
  211. pass
  212. if "ERR3" in err_str or "unavailable" in err_str:
  213. _LOGGER.debug(
  214. "Projector is busy switching inputs. Reconnecting next poll."
  215. )
  216. else:
  217. if not self._connectionErrorLogged:
  218. _LOGGER.error(
  219. "PJLink2 ERROR for %s: %s", self._name, err_str
  220. )
  221. self._connectionErrorLogged = True
  222. self._state = MediaPlayerState.OFF