feat: initial commit

This commit is contained in:
devilr33f 2025-11-04 11:21:01 +06:00
commit e436fafd9e
13 changed files with 812 additions and 0 deletions

210
.gitignore vendored Normal file
View 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
View 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).

View 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

View 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

View 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)

View 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"

View 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"),
}

View 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")

View 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"]
}

View 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)

View 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"
}
}
}
}
}

View 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"
}
}
}
}
}

View 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")