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