d6956395c8
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>
151 lines
5.2 KiB
Python
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)
|