feat: initial commit
This commit is contained in:
commit
e436fafd9e
13 changed files with 812 additions and 0 deletions
210
.gitignore
vendored
Normal file
210
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# Created by https://www.toptal.com/developers/gitignore/api/python,venv,visualstudiocode
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python,venv,visualstudiocode
|
||||
|
||||
scripts
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### venv ###
|
||||
# Virtualenv
|
||||
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
|
||||
[Bb]in
|
||||
[Ii]nclude
|
||||
[Ll]ib
|
||||
[Ll]ib64
|
||||
[Ll]ocal
|
||||
[Ss]cripts
|
||||
pyvenv.cfg
|
||||
pip-selfcheck.json
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python,venv,visualstudiocode
|
||||
|
||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 2GIS Weather – Home Assistant Custom Integration
|
||||
|
||||
Custom integration to fetch weather from 2GIS and expose a Weather entity with hourly/daily forecasts and extra sensors.
|
||||
|
||||
## Install (local dev)
|
||||
|
||||
- Copy `custom_components/two_gis_weather` into your Home Assistant `config/custom_components/` directory.
|
||||
- Restart Home Assistant.
|
||||
|
||||
## Configure
|
||||
|
||||
- In Home Assistant: Settings → Devices & Services → Add Integration → 2GIS Weather
|
||||
- Provide your API key and coordinates (defaults to your HA location).
|
||||
|
||||
|
||||
29
custom_components/two_gis_weather/__init__.py
Normal file
29
custom_components/two_gis_weather/__init__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import TwoGisWeatherUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, _config: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = TwoGisWeatherUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
return unload_ok
|
||||
|
||||
|
||||
67
custom_components/two_gis_weather/api.py
Normal file
67
custom_components/two_gis_weather/api.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from aiohttp import ClientResponse
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import API_BASE, DEFAULT_USER_AGENT, REQUEST_TIMEOUT
|
||||
|
||||
|
||||
class TwoGisWeatherClient:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
api_key: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
language: str = "en-US",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
) -> None:
|
||||
self.hass = hass
|
||||
self.api_key = api_key
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.language = language
|
||||
self.user_agent = user_agent
|
||||
self._last_modified: str | None = None
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": self.user_agent,
|
||||
"x-request-id": uuid.uuid4().hex,
|
||||
"Accept-Language": self.language,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self._last_modified:
|
||||
headers["If-Modified-Since"] = self._last_modified
|
||||
return headers
|
||||
|
||||
async def async_get_current(self) -> dict[str, Any] | None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
url = f"{API_BASE}/forecast"
|
||||
params = {
|
||||
"lon": str(self.longitude),
|
||||
"lat": str(self.latitude),
|
||||
"key": self.api_key,
|
||||
}
|
||||
async with async_timeout.timeout(REQUEST_TIMEOUT):
|
||||
resp: ClientResponse
|
||||
async with session.get(url, params=params, headers=self._headers()) as resp:
|
||||
if resp.status == 304:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
# Track Last-Modified for conditional requests
|
||||
last_mod = resp.headers.get("Last-Modified")
|
||||
if last_mod:
|
||||
self._last_modified = last_mod
|
||||
return await resp.json(content_type=None)
|
||||
|
||||
@property
|
||||
def last_modified(self) -> str | None:
|
||||
return self._last_modified
|
||||
|
||||
|
||||
104
custom_components/two_gis_weather/config_flow.py
Normal file
104
custom_components/two_gis_weather/config_flow.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import asyncio
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_API_KEY,
|
||||
CONF_NAME,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_LANGUAGE,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
def _default_lat_lon(hass: HomeAssistant) -> tuple[float | None, float | None]:
|
||||
return hass.config.latitude, hass.config.longitude
|
||||
|
||||
|
||||
from .api import TwoGisWeatherClient
|
||||
|
||||
|
||||
class TwoGisWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
name = user_input.get(CONF_NAME) or "2GIS Weather"
|
||||
lat = float(user_input.get(CONF_LATITUDE) or self.hass.config.latitude)
|
||||
lon = float(user_input.get(CONF_LONGITUDE) or self.hass.config.longitude)
|
||||
lang = user_input.get(CONF_LANGUAGE) or "en-US"
|
||||
|
||||
# Prevent duplicates for the same coordinates
|
||||
await self.async_set_unique_id(f"{lat:.4f},{lon:.4f}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
client = TwoGisWeatherClient(self.hass, api_key, lat, lon, lang)
|
||||
try:
|
||||
await client.async_get_current()
|
||||
except ClientResponseError as e: # HTTP error from server
|
||||
errors["base"] = "invalid_auth" if e.status in (401, 403) else "cannot_connect"
|
||||
except (asyncio.TimeoutError, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_API_KEY: api_key,
|
||||
CONF_NAME: name,
|
||||
CONF_LATITUDE: lat,
|
||||
CONF_LONGITUDE: lon,
|
||||
CONF_LANGUAGE: lang,
|
||||
},
|
||||
)
|
||||
|
||||
lat, lon = _default_lat_lon(self.hass)
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(CONF_NAME, default="2GIS Weather"): str,
|
||||
vol.Optional(CONF_LATITUDE, default=lat): float,
|
||||
vol.Optional(CONF_LONGITUDE, default=lon): float,
|
||||
vol.Optional(CONF_LANGUAGE, default="en-US"): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema)
|
||||
|
||||
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
|
||||
return await self.async_step_user(import_config)
|
||||
|
||||
|
||||
class TwoGisWeatherOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="Options", data=user_input)
|
||||
|
||||
current = {**self.config_entry.options}
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_UPDATE_INTERVAL, default=current.get(CONF_UPDATE_INTERVAL, 10)): int,
|
||||
vol.Optional(CONF_LANGUAGE, default=current.get(CONF_LANGUAGE, "en-US")): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=schema)
|
||||
|
||||
|
||||
async def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> TwoGisWeatherOptionsFlowHandler: # type: ignore[override]
|
||||
return TwoGisWeatherOptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
23
custom_components/two_gis_weather/const.py
Normal file
23
custom_components/two_gis_weather/const.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from homeassistant.const import Platform
|
||||
|
||||
|
||||
DOMAIN = "two_gis_weather"
|
||||
PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.SENSOR]
|
||||
|
||||
API_BASE = "https://mobile-weather.api.2gis.ru/api/1.0"
|
||||
DEFAULT_USER_AGENT = "2gis/4.0 v4-iphone/7.13.1 (iPhone17,1; iOS 26.0.1; V2:37d4101b-dca7-42a2-ae41-fdc69d122794) application"
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
CONF_API_KEY = "api_key"
|
||||
CONF_NAME = "name"
|
||||
CONF_LATITUDE = "latitude"
|
||||
CONF_LONGITUDE = "longitude"
|
||||
CONF_LANGUAGE = "language"
|
||||
CONF_UPDATE_INTERVAL = "update_interval"
|
||||
|
||||
|
||||
130
custom_components/two_gis_weather/coordinator.py
Normal file
130
custom_components/two_gis_weather/coordinator.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_LANGUAGE
|
||||
from .api import TwoGisWeatherClient
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TwoGisWeatherUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.entry = entry
|
||||
api_key: str = entry.data[CONF_API_KEY]
|
||||
latitude: float = float(entry.data.get(CONF_LATITUDE, hass.config.latitude))
|
||||
longitude: float = float(entry.data.get(CONF_LONGITUDE, hass.config.longitude))
|
||||
language: str = entry.data.get(CONF_LANGUAGE, "en-US")
|
||||
self.client = TwoGisWeatherClient(hass, api_key, latitude, longitude, language)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"2GIS Weather ({latitude},{longitude})",
|
||||
update_interval=DEFAULT_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
try:
|
||||
raw = await self.client.async_get_current()
|
||||
except Exception as err: # noqa: BLE001 - narrowed at integration time
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
if not raw:
|
||||
# Not modified: keep previous data
|
||||
return self.data or {}
|
||||
|
||||
return _normalize_payload(raw)
|
||||
|
||||
|
||||
def _mmhg_to_hpa(value: float | int | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
return round(float(value) * 1.33322, 1)
|
||||
|
||||
|
||||
def _tzinfo_from_offset(offset: str | None) -> timezone:
|
||||
if not offset:
|
||||
return timezone.utc
|
||||
sign = 1 if offset.startswith("+") else -1
|
||||
try:
|
||||
hours_str, mins_str = offset[1:].split(":", 1)
|
||||
hours = int(hours_str)
|
||||
minutes = int(mins_str)
|
||||
except Exception: # noqa: BLE001
|
||||
return timezone.utc
|
||||
return timezone(sign * timedelta(hours=hours, minutes=minutes))
|
||||
|
||||
|
||||
def _normalize_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
from .icon_map import to_ha_condition # local import to avoid circulars
|
||||
|
||||
tzinfo = _tzinfo_from_offset(data.get("tz_offset"))
|
||||
cur = data.get("current", {})
|
||||
temp = (cur.get("temperature") or {})
|
||||
wind = (cur.get("wind") or {})
|
||||
pressure = (cur.get("pressure") or {})
|
||||
icon = cur.get("icon")
|
||||
time_of_day = cur.get("time_of_day")
|
||||
|
||||
current: dict[str, Any] = {
|
||||
"temperature": temp.get("air"),
|
||||
"feels_like": temp.get("feels"),
|
||||
"wind_speed": wind.get("speed"),
|
||||
"wind_bearing": (wind.get("direction") or "").upper() or None,
|
||||
"pressure": _mmhg_to_hpa(pressure.get("value")),
|
||||
"description": cur.get("weather_description"),
|
||||
"icon": icon,
|
||||
"condition": to_ha_condition(icon, time_of_day),
|
||||
}
|
||||
|
||||
hourly: list[dict[str, Any]] = []
|
||||
for item in data.get("forecast_hourly", []) or []:
|
||||
ts = item.get("date")
|
||||
icon_h = item.get("icon")
|
||||
temp_h = (item.get("temperature") or {}).get("air")
|
||||
if ts is None:
|
||||
continue
|
||||
dt = datetime.fromtimestamp(int(ts), tz=tzinfo)
|
||||
hourly.append(
|
||||
{
|
||||
"datetime": dt,
|
||||
"temperature": temp_h,
|
||||
"condition": to_ha_condition(icon_h, time_of_day),
|
||||
}
|
||||
)
|
||||
|
||||
daily: list[dict[str, Any]] = []
|
||||
for item in data.get("forecast_daily", []) or []:
|
||||
ts = item.get("date")
|
||||
temps = item.get("temperature") or {}
|
||||
tmin = (temps.get("min") or {}).get("air")
|
||||
tmax = (temps.get("max") or {}).get("air")
|
||||
icon_d = item.get("icon")
|
||||
if ts is None:
|
||||
continue
|
||||
dt = datetime.fromtimestamp(int(ts), tz=tzinfo)
|
||||
daily.append(
|
||||
{
|
||||
"datetime": dt,
|
||||
"temperature": tmax,
|
||||
"templow": tmin,
|
||||
"condition": to_ha_condition(icon_d, time_of_day),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"current": current,
|
||||
"hourly": hourly,
|
||||
"daily": daily,
|
||||
"tz_offset": data.get("tz_offset"),
|
||||
}
|
||||
|
||||
|
||||
16
custom_components/two_gis_weather/icon_map.py
Normal file
16
custom_components/two_gis_weather/icon_map.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def to_ha_condition(icon: str, time_of_day: str | None) -> str:
|
||||
if icon == "d":
|
||||
return "sunny" if time_of_day == "day" else "clear-night"
|
||||
mapping = {
|
||||
"c": "cloudy",
|
||||
"c_s": "snowy",
|
||||
"c_r": "rainy",
|
||||
"c_rs": "snowy-rainy",
|
||||
"d_c": "partlycloudy",
|
||||
}
|
||||
return mapping.get(icon, "cloudy")
|
||||
|
||||
|
||||
14
custom_components/two_gis_weather/manifest.json
Normal file
14
custom_components/two_gis_weather/manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"domain": "two_gis_weather",
|
||||
"name": "2GIS Weather",
|
||||
"version": "0.1.0",
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/devilr33f/ha-2gis-weather",
|
||||
"issue_tracker": "https://github.com/devilr33f/ha-2gis-weather/issues",
|
||||
"iot_class": "cloud_polling",
|
||||
"integration_type": "hub",
|
||||
"requirements": [],
|
||||
"codeowners": ["@devilr33f"],
|
||||
"loggers": ["custom_components.two_gis_weather"]
|
||||
}
|
||||
|
||||
57
custom_components/two_gis_weather/sensor.py
Normal file
57
custom_components/two_gis_weather/sensor.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import TwoGisWeatherUpdateCoordinator
|
||||
|
||||
|
||||
SENSOR_SPECS = [
|
||||
("feels_like", "Feels like", UnitOfTemperature.CELSIUS),
|
||||
("wind_speed", "Wind speed", UnitOfSpeed.METERS_PER_SECOND),
|
||||
("wind_bearing", "Wind bearing", None),
|
||||
("pressure", "Pressure", UnitOfPressure.HPA),
|
||||
("description", "Weather description", None),
|
||||
("icon", "2GIS icon", None),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
coordinator: TwoGisWeatherUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities: list[SensorEntity] = []
|
||||
for key, name, unit in SENSOR_SPECS:
|
||||
entities.append(TwoGisWeatherSensor(coordinator, entry, key, name, unit))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class TwoGisWeatherSensor(CoordinatorEntity[TwoGisWeatherUpdateCoordinator], SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TwoGisWeatherUpdateCoordinator,
|
||||
entry: ConfigEntry,
|
||||
key: str,
|
||||
name: str,
|
||||
unit: str | None,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._entry = entry
|
||||
self._key = key
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f"{entry.entry_id}_sensor_{key}"
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
current = (self.coordinator.data or {}).get("current", {})
|
||||
return current.get(self._key)
|
||||
|
||||
|
||||
33
custom_components/two_gis_weather/strings.json
Normal file
33
custom_components/two_gis_weather/strings.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "2GIS Weather",
|
||||
"description": "Configure 2GIS Weather integration",
|
||||
"data": {
|
||||
"api_key": "API key",
|
||||
"name": "Name",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"language": "Language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid API key",
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"update_interval": "Update interval (minutes)",
|
||||
"language": "Language"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
custom_components/two_gis_weather/translations/en.json
Normal file
33
custom_components/two_gis_weather/translations/en.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "2GIS Weather",
|
||||
"description": "Configure 2GIS Weather integration",
|
||||
"data": {
|
||||
"api_key": "API key",
|
||||
"name": "Name",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"language": "Language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid API key",
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"update_interval": "Update interval (minutes)",
|
||||
"language": "Language"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
custom_components/two_gis_weather/weather.py
Normal file
81
custom_components/two_gis_weather/weather.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
WeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import TwoGisWeatherUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
coordinator: TwoGisWeatherUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([TwoGisWeatherEntity(coordinator, entry)])
|
||||
|
||||
|
||||
class TwoGisWeatherEntity(CoordinatorEntity[TwoGisWeatherUpdateCoordinator], WeatherEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: TwoGisWeatherUpdateCoordinator, entry: ConfigEntry) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_weather"
|
||||
self._attr_name = entry.data.get("name") or "2GIS Weather"
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
current = self.coordinator.data.get("current") if self.coordinator.data else None
|
||||
return (current or {}).get("temperature")
|
||||
|
||||
@property
|
||||
def native_temperature_unit(self) -> str:
|
||||
return "°C"
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
current = self.coordinator.data.get("current") if self.coordinator.data else None
|
||||
return (current or {}).get("condition")
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
current = (self.coordinator.data or {}).get("current", {})
|
||||
return current.get("wind_speed")
|
||||
|
||||
@property
|
||||
def native_wind_speed_unit(self) -> str:
|
||||
return "m/s"
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> str | None:
|
||||
current = (self.coordinator.data or {}).get("current", {})
|
||||
return current.get("wind_bearing")
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float | None:
|
||||
current = (self.coordinator.data or {}).get("current", {})
|
||||
return current.get("pressure")
|
||||
|
||||
@property
|
||||
def native_pressure_unit(self) -> str:
|
||||
return "hPa"
|
||||
|
||||
async def async_forecast_daily(self) -> list[dict[str, Any]] | None:
|
||||
return (self.coordinator.data or {}).get("daily")
|
||||
|
||||
async def async_forecast_hourly(self) -> list[dict[str, Any]] | None:
|
||||
return (self.coordinator.data or {}).get("hourly")
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue