Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for the Transmission BitTorrent client API."""
2 
3 from __future__ import annotations
4 
5 from functools import partial
6 import logging
7 import re
8 from typing import Any, Final
9 
10 import transmission_rpc
11 from transmission_rpc.error import (
12  TransmissionAuthError,
13  TransmissionConnectError,
14  TransmissionError,
15 )
16 import voluptuous as vol
17 
18 from homeassistant.config_entries import ConfigEntry, ConfigEntryState
19 from homeassistant.const import (
20  CONF_HOST,
21  CONF_ID,
22  CONF_NAME,
23  CONF_PASSWORD,
24  CONF_PATH,
25  CONF_PORT,
26  CONF_SSL,
27  CONF_USERNAME,
28  Platform,
29 )
30 from homeassistant.core import HomeAssistant, ServiceCall, callback
31 from homeassistant.exceptions import (
32  ConfigEntryAuthFailed,
33  ConfigEntryNotReady,
34  HomeAssistantError,
35 )
36 from homeassistant.helpers import (
37  config_validation as cv,
38  entity_registry as er,
39  selector,
40 )
41 from homeassistant.helpers.typing import ConfigType
42 
43 from .const import (
44  ATTR_DELETE_DATA,
45  ATTR_TORRENT,
46  CONF_ENTRY_ID,
47  DEFAULT_DELETE_DATA,
48  DEFAULT_PATH,
49  DEFAULT_SSL,
50  DOMAIN,
51  SERVICE_ADD_TORRENT,
52  SERVICE_REMOVE_TORRENT,
53  SERVICE_START_TORRENT,
54  SERVICE_STOP_TORRENT,
55 )
56 from .coordinator import TransmissionDataUpdateCoordinator
57 from .errors import AuthenticationError, CannotConnect, UnknownError
58 
59 _LOGGER = logging.getLogger(__name__)
60 
61 PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
62 
63 MIGRATION_NAME_TO_KEY = {
64  # Sensors
65  "Down Speed": "download",
66  "Up Speed": "upload",
67  "Status": "status",
68  "Active Torrents": "active_torrents",
69  "Paused Torrents": "paused_torrents",
70  "Total Torrents": "total_torrents",
71  "Completed Torrents": "completed_torrents",
72  "Started Torrents": "started_torrents",
73  # Switches
74  "Switch": "on_off",
75  "Turtle Mode": "turtle_mode",
76 }
77 
78 SERVICE_BASE_SCHEMA = vol.Schema(
79  {
80  vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(),
81  }
82 )
83 
84 SERVICE_ADD_TORRENT_SCHEMA = vol.All(
85  SERVICE_BASE_SCHEMA.extend({vol.Required(ATTR_TORRENT): cv.string}),
86 )
87 
88 
89 SERVICE_REMOVE_TORRENT_SCHEMA = vol.All(
90  SERVICE_BASE_SCHEMA.extend(
91  {
92  vol.Required(CONF_ID): cv.positive_int,
93  vol.Optional(ATTR_DELETE_DATA, default=DEFAULT_DELETE_DATA): cv.boolean,
94  }
95  )
96 )
97 
98 SERVICE_START_TORRENT_SCHEMA = vol.All(
99  SERVICE_BASE_SCHEMA.extend({vol.Required(CONF_ID): cv.positive_int}),
100 )
101 
102 SERVICE_STOP_TORRENT_SCHEMA = vol.All(
103  SERVICE_BASE_SCHEMA.extend(
104  {
105  vol.Required(CONF_ID): cv.positive_int,
106  }
107  )
108 )
109 
110 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
111 
112 type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator]
113 
114 
115 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
116  """Set up the Transmission component."""
117  setup_hass_services(hass)
118  return True
119 
120 
122  hass: HomeAssistant, config_entry: TransmissionConfigEntry
123 ) -> bool:
124  """Set up the Transmission Component."""
125 
126  @callback
127  def update_unique_id(
128  entity_entry: er.RegistryEntry,
129  ) -> dict[str, Any] | None:
130  """Update unique ID of entity entry."""
131  if CONF_NAME not in config_entry.data:
132  return None
133  match = re.search(
134  f"{config_entry.data[CONF_HOST]}-{config_entry.data[CONF_NAME]} (?P<name>.+)",
135  entity_entry.unique_id,
136  )
137 
138  if match and (key := MIGRATION_NAME_TO_KEY.get(match.group("name"))):
139  return {"new_unique_id": f"{config_entry.entry_id}-{key}"}
140  return None
141 
142  await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
143 
144  try:
145  api = await get_api(hass, dict(config_entry.data))
146  except CannotConnect as error:
147  raise ConfigEntryNotReady from error
148  except (AuthenticationError, UnknownError) as error:
149  raise ConfigEntryAuthFailed from error
150 
151  coordinator = TransmissionDataUpdateCoordinator(hass, config_entry, api)
152  await hass.async_add_executor_job(coordinator.init_torrent_list)
153 
154  await coordinator.async_config_entry_first_refresh()
155  config_entry.runtime_data = coordinator
156 
157  await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
158 
159  return True
160 
161 
162 async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
163  """Unload Transmission Entry from config_entry."""
164  return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
165 
166 
167 async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
168  """Migrate an old config entry."""
169  _LOGGER.debug(
170  "Migrating from version %s.%s",
171  config_entry.version,
172  config_entry.minor_version,
173  )
174 
175  if config_entry.version == 1:
176  # Version 1.2 adds ssl and path
177  if config_entry.minor_version < 2:
178  new = {**config_entry.data}
179 
180  new[CONF_PATH] = DEFAULT_PATH
181  new[CONF_SSL] = DEFAULT_SSL
182 
183  hass.config_entries.async_update_entry(
184  config_entry, data=new, version=1, minor_version=2
185  )
186 
187  _LOGGER.debug(
188  "Migration to version %s.%s successful",
189  config_entry.version,
190  config_entry.minor_version,
191  )
192 
193  return True
194 
195 
197  hass: HomeAssistant, entry_id: str
198 ) -> TransmissionDataUpdateCoordinator:
199  """Return coordinator for entry id."""
200  entry: TransmissionConfigEntry | None = hass.config_entries.async_get_entry(
201  entry_id
202  )
203  if entry is None or entry.state is not ConfigEntryState.LOADED:
204  raise HomeAssistantError(f"Config entry {entry_id} is not found or not loaded")
205  return entry.runtime_data
206 
207 
208 def setup_hass_services(hass: HomeAssistant) -> None:
209  """Home Assistant services."""
210 
211  async def add_torrent(service: ServiceCall) -> None:
212  """Add new torrent to download."""
213  entry_id: str = service.data[CONF_ENTRY_ID]
214  coordinator = _get_coordinator_from_service_data(hass, entry_id)
215  torrent: str = service.data[ATTR_TORRENT]
216  if torrent.startswith(
217  ("http", "ftp:", "magnet:")
218  ) or hass.config.is_allowed_path(torrent):
219  await hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
220  await coordinator.async_request_refresh()
221  else:
222  _LOGGER.warning("Could not add torrent: unsupported type or no permission")
223 
224  async def start_torrent(service: ServiceCall) -> None:
225  """Start torrent."""
226  entry_id: str = service.data[CONF_ENTRY_ID]
227  coordinator = _get_coordinator_from_service_data(hass, entry_id)
228  torrent_id = service.data[CONF_ID]
229  await hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id)
230  await coordinator.async_request_refresh()
231 
232  async def stop_torrent(service: ServiceCall) -> None:
233  """Stop torrent."""
234  entry_id: str = service.data[CONF_ENTRY_ID]
235  coordinator = _get_coordinator_from_service_data(hass, entry_id)
236  torrent_id = service.data[CONF_ID]
237  await hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id)
238  await coordinator.async_request_refresh()
239 
240  async def remove_torrent(service: ServiceCall) -> None:
241  """Remove torrent."""
242  entry_id: str = service.data[CONF_ENTRY_ID]
243  coordinator = _get_coordinator_from_service_data(hass, entry_id)
244  torrent_id = service.data[CONF_ID]
245  delete_data = service.data[ATTR_DELETE_DATA]
246  await hass.async_add_executor_job(
247  partial(coordinator.api.remove_torrent, torrent_id, delete_data=delete_data)
248  )
249  await coordinator.async_request_refresh()
250 
251  hass.services.async_register(
252  DOMAIN, SERVICE_ADD_TORRENT, add_torrent, schema=SERVICE_ADD_TORRENT_SCHEMA
253  )
254 
255  hass.services.async_register(
256  DOMAIN,
257  SERVICE_REMOVE_TORRENT,
258  remove_torrent,
259  schema=SERVICE_REMOVE_TORRENT_SCHEMA,
260  )
261 
262  hass.services.async_register(
263  DOMAIN,
264  SERVICE_START_TORRENT,
265  start_torrent,
266  schema=SERVICE_START_TORRENT_SCHEMA,
267  )
268 
269  hass.services.async_register(
270  DOMAIN,
271  SERVICE_STOP_TORRENT,
272  stop_torrent,
273  schema=SERVICE_STOP_TORRENT_SCHEMA,
274  )
275 
276 
277 async def get_api(
278  hass: HomeAssistant, entry: dict[str, Any]
279 ) -> transmission_rpc.Client:
280  """Get Transmission client."""
281  protocol: Final = "https" if entry[CONF_SSL] else "http"
282  host = entry[CONF_HOST]
283  port = entry[CONF_PORT]
284  path = entry[CONF_PATH]
285  username = entry.get(CONF_USERNAME)
286  password = entry.get(CONF_PASSWORD)
287 
288  try:
289  api = await hass.async_add_executor_job(
290  partial(
291  transmission_rpc.Client,
292  username=username,
293  password=password,
294  protocol=protocol,
295  host=host,
296  port=port,
297  path=path,
298  )
299  )
300  except TransmissionAuthError as error:
301  _LOGGER.error("Credentials for Transmission client are not valid")
302  raise AuthenticationError from error
303  except TransmissionConnectError as error:
304  _LOGGER.error("Connecting to the Transmission client %s failed", host)
305  raise CannotConnect from error
306  except TransmissionError as error:
307  _LOGGER.error(error)
308  raise UnknownError from error
309  _LOGGER.debug("Successfully connected to %s", host)
310  return api
dict[str, str]|None update_unique_id(er.RegistryEntry entity_entry, str unique_id)
Definition: __init__.py:168
bool async_setup_entry(HomeAssistant hass, TransmissionConfigEntry config_entry)
Definition: __init__.py:123
TransmissionDataUpdateCoordinator _get_coordinator_from_service_data(HomeAssistant hass, str entry_id)
Definition: __init__.py:198
bool async_migrate_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:167
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:115
None setup_hass_services(HomeAssistant hass)
Definition: __init__.py:208
transmission_rpc.Client get_api(HomeAssistant hass, dict[str, Any] entry)
Definition: __init__.py:279
bool async_unload_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:162