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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user