Files
GsmNode/Home Assistant Plugin/custom_components/gsmnode/notify.py
T
tajniak81 d6956395c8 Rebrand CommGate to gsmnode and adopt new design system
Rename the whole project from CommGate to gsmnode and re-skin every
surface onto the new signal-green + ink design system (Space Grotesk /
IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark,
lowercase gsm+node wordmark).

- Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme,
  new logo/favicon assets; both builds verified.
- Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive
  launcher icons; rename Android applicationId/package to app.gsmnode.phone;
  bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified.
- API Server (Go): panel routes, User-Agent, health service id, branding.
- Home Assistant Plugin: rename integration domain sms_gateway to gsmnode
  (folder, DOMAIN, services, entity ids, classes); py_compile verified.
- Update all READMEs; add .gitignore (excludes Design/, build artifacts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:53:14 +02:00

151 lines
5.2 KiB
Python

"""gsmnode notify platform.
Sends SMS through the gsmnode API Server's `/api/messages` endpoint. The
API Server is the only thing that talks to PocketBase, so this integration only
needs the API Server's URL and a user login.
"""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_TARGET,
PLATFORM_SCHEMA,
BaseNotificationService,
)
from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
CONF_API_BASE,
CONF_DEVICE_ID,
DEFAULT_API_BASE,
DEFAULT_NAME,
)
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_API_BASE, default=DEFAULT_API_BASE): cv.string,
vol.Required(CONF_EMAIL): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_DEVICE_ID): cv.string,
}
)
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> "GsmNodeNotificationService":
"""Return the gsmnode notification service."""
return GsmNodeNotificationService(
hass,
config[CONF_API_BASE],
config[CONF_EMAIL],
config[CONF_PASSWORD],
config.get(CONF_DEVICE_ID),
)
class GsmNodeNotificationService(BaseNotificationService):
"""Implement the notification service for gsmnode."""
def __init__(
self,
hass: HomeAssistant,
api_base: str,
email: str,
password: str,
device_id: str | None,
) -> None:
"""Initialize the service."""
self._hass = hass
self._api_base = api_base.rstrip("/")
self._email = email
self._password = password
self._device_id = device_id
self._token: str | None = None
@property
def _session(self):
return async_get_clientsession(self._hass)
async def _login(self) -> None:
"""Authenticate against the API Server and cache the JWT."""
url = f"{self._api_base}/api/auth/login"
async with self._session.post(
url, json={"email": self._email, "password": self._password}
) as resp:
if resp.status != 200:
raise RuntimeError(f"login failed: HTTP {resp.status}")
data = await resp.json()
self._token = data.get("access_token")
if not self._token:
raise RuntimeError("login response did not contain a token")
async def _post(self, path: str, payload: dict[str, Any]) -> int:
"""POST to an API path; returns the HTTP status code."""
url = f"{self._api_base}{path}"
headers = {"Authorization": f"Bearer {self._token}"}
async with self._session.post(url, json=payload, headers=headers) as resp:
return resp.status
async def _send(self, path: str, payload: dict[str, Any]) -> None:
"""Authenticate (if needed) and POST, retrying once on a 401."""
try:
if not self._token:
await self._login()
status = await self._post(path, payload)
if status == 401: # token expired — re-login once and retry
await self._login()
status = await self._post(path, payload)
if status not in (200, 201, 202):
_LOGGER.error("gsmnode: %s failed with HTTP %s", path, status)
except Exception as err: # noqa: BLE001 - surface any transport error
_LOGGER.error("gsmnode: error calling %s: %s", path, err)
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send an SMS, or place a phone call when `data.type` is `call`.
Recipient numbers come from the `target` field. Optional data overrides:
`device_id` (which device), `sim_number` (SMS only), and `type: call` to
dial the target(s) instead of texting.
"""
targets = kwargs.get(ATTR_TARGET)
if not targets:
_LOGGER.error("gsmnode: no target phone number(s) provided")
return
data = kwargs.get(ATTR_DATA) or {}
device_id = data.get("device_id", self._device_id)
is_call = str(data.get("type", "")).lower() == "call"
if is_call:
# One call per target number (a call has a single recipient).
for number in targets:
payload: dict[str, Any] = {"phone_number": number}
if device_id:
payload["device_id"] = device_id
await self._send("/api/calls", payload)
return
payload = {"phone_numbers": list(targets), "text_message": message}
if device_id:
payload["device_id"] = device_id
if "sim_number" in data:
payload["sim_number"] = data["sim_number"]
await self._send("/api/messages", payload)