Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gsmnode — Home Assistant Integration

A custom Home Assistant integration that sends SMS, places phone calls, and hears back from the gateway when messages and calls arrive — through the gsmnode API Server.

Home Assistant ──► API Server (/api/messages, /api/calls) ──► your phone ──► SMS / call
Home Assistant ◄── API Server (webhooks)                  ◄── your phone ◄── SMS / call

Like every other client, it talks only to the API Server (never PocketBase).

There is no YAML. Every part of this — the connection, the sidebar item, which events arrive, and every notification target — is set from the Home Assistant UI, and nothing goes in configuration.yaml.

What you get

  • UI setup (config flow) — add it under Settings → Devices & Services, and change the URL or password later without losing your entities (Reconfigure).
  • Actionsgsmnode.send takes the lot: SMS, MMS or a call, to whichever numbers, from whichever phone and SIM, now or later. gsmnode.send_sms and gsmnode.call remain as short forms. The phone is a picker, not a typed id.
  • Sensors — a binary_sensor "API Server" (is the gateway up?) plus one connectivity sensor per registered phone, so an automation can react to the phone that actually sends your texts dropping off.
  • Incoming events (optional) — tick the gateway events you care about and they arrive on the Home Assistant bus, ready for an Event trigger. The webhook is registered at both ends for you. See below.
  • Notification targets (optional) — add as many as you like, each its own notify entity with its own type, phone, SIM and recipients. See below.
  • Sidebar item (optional) — a gsmnode entry in Home Assistant's left menu that opens the Web App or the API Server panel in place. See below.
  • Branding — the gsmnode mark and wordmark ship in brand/, so the integration carries its own icon instead of a puzzle piece (Home Assistant 2026.3+; older versions ignore the folder). The two actions get icons on any version through icons.json.

Install

Needs Home Assistant 2025.3 or newer — notification targets are config subentries, which older versions cannot show.

With HACS

Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.

Until it lands in the HACS default store, add it as a custom repository:

  1. HACS → ⋮ (top right) → Custom repositories.
  2. Repository https://github.com/gsmnode/gsmnode-ha, type IntegrationAdd.
  3. Search HACS for gsmnodeDownload.
  4. Restart Home Assistant (Settings → System → Restart).

The button above does steps 1–3 for you. Once the integration is in the default store, none of this is needed — it will simply be there to search for.

By hand

  1. Copy custom_components/gsmnode/ from this repository into your Home Assistant config directory, so it lands at:

    <config>/custom_components/gsmnode/
    

    That is next to your configuration.yaml. Use the Samba / File editor / SSH add-on, or the mapped volume for Docker.

  2. Restart Home Assistant (Settings → System → Restart).

Add it from the UI

  1. Settings → Devices & Services → Add Integration.

  2. Search for gsmnode.

  3. Fill in the form:

    • API Server URL — e.g. http://192.0.2.10:8080 (must be reachable from HA)
    • Email / Password — a gateway user. Make one in the Web App, or on the API Server host with node scripts/create-user.mjs ha@local "pass" "Home Assistant"
    • Default phone (optional) — pin sends/calls to a specific phone, by its device ID

    The form validates by logging in; you'll get Invalid auth or Cannot connect if something's wrong.

A gsmnode device appears with an API Server connectivity sensor, and each phone registered to that account gets its own device and sensor beneath it.

If the password changes on the gateway, Home Assistant raises a Reauthentication notification instead of silently failing; if the server moves, use Reconfigure on the integration.

Use it

Send anything

gsmnode.send is one action for all three kinds. Every field below has a picker in the automation editor — the type is a dropdown, the phone is a device list, the SIM is a number box:

action: gsmnode.send
data:
  type: sms                 # sms | mms | call
  phone_numbers: ["+15551234567"]
  message: "Hello from Home Assistant"
  device: 4a7f…             # optional: which phone, from the device picker
  sim_number: 0             # optional: 0-based slot, works for calls too
  schedule_at: "2026-07-22 18:30:00"   # optional (not for calls)

An MMS adds a subject and attachments; a call ignores the message and rings each number in turn:

action: gsmnode.send
data:
  type: mms
  phone_numbers: ["+15551234567"]
  subject: "Front door"
  message: "Someone at the door"
  attachments: ["/config/www/snapshot.jpg"]
action: gsmnode.send
data:
  type: call
  phone_numbers: ["+15551234567"]
  sim_number: 1

Attachments are read off disk, so each path has to be under an allowlist_external_dirs directory, and each file at most 1 MB.

sim_number here is a slot index, not a picker. Action fields come from a static services.yaml that Home Assistant reads once for every install, so it cannot list SIMs that differ per gateway and per phone — only a config flow can do that, which is why notification targets get the real list. To see which slot is which, open a phone's connectivity sensor: its attributes carry the same slot, carrier and number the picker uses.

Short forms

action: gsmnode.send_sms
data:
  phone_numbers: ["+15551234567"]
  message: "Hello from Home Assistant"
  sim_number: 0         # optional (dual-SIM; slots count from 0)
action: gsmnode.call
data:
  phone_number: "+15551234567"
  sim_number: 1         # optional

With more than one gateway configured, add config_entry_id: to say which one to use — the automation editor offers a picker for it. device (the picker) and device_id (the gateway's own id) are interchangeable; device_id wins if both are given.

Example automation

# Built in the automation editor; this is only what it looks like underneath.
triggers:
  - trigger: state
    entity_id: binary_sensor.basement_leak
    to: "on"
actions:
  - action: gsmnode.send_sms
    data:
      phone_numbers: ["+15551234567"]
      message: "Water leak detected in the basement!"
  - action: gsmnode.call
    data:
      phone_number: "+15551234567"

React to the gateway going offline

triggers:
  - trigger: state
    entity_id: binary_sensor.gsmnode_api_server
    to: "off"
    for: "00:02:00"
actions:
  - action: persistent_notification.create
    data:
      title: "gsmnode"
      message: "The API Server is unreachable."

The same trigger shape works on a phone's own sensor (its entity id comes from the phone's name) — useful when the API Server is up but the phone that does the sending has stopped routing.

The sidebar item

The gateway already has two full overviews — the Web App and the API Server's own panel — so the integration does not reimplement either. It puts an item in Home Assistant's sidebar that opens the one you choose, in place, without leaving Home Assistant.

Set it up under Settings → Devices & Services → gsmnode → Configure → Sidebar item:

Overview to show Opens
No sidebar item nothing — the default
Web App the full gateway UI: messages, inbox, devices, webhooks, settings
API Server panel status and administration: users, plugins, connected devices
Another address any URL you give it

The address is worked out for you: the API Server panel is the URL you signed in against, and the Web App's is whatever the API Server reports for it on /api/status — both shown in the form. Fill the Address field in only when that is not the address your browser needs. The panel is loaded by the browser, not by Home Assistant, so a container name or a localhost that means something different on the two machines has to be overridden here.

Sidebar title names the item — worth setting if you run more than one gateway — and Administrators only hides it from non-admin users. Changing any of this reloads the integration and the sidebar updates immediately.

Two limits worth knowing before you wonder why a page is blank:

  • A page served over http cannot be embedded in a Home Assistant served over https. Browsers block the mixed content and Home Assistant shows an error in place of the frame. Put the gateway behind the same kind of TLS Home Assistant uses, or open it in its own tab.
  • The embedded app keeps its own login. Signing in to Home Assistant does not sign you in to the Web App; that session lives in the frame and persists there like it would in a tab.

Incoming events

Sending is only half a gateway. Configure → Incoming events ticks what the gateway should tell Home Assistant about:

Event Fires as Carries
sms:received gsmnode_sms_received phone_number, message, received_at, sim_slot, encrypted
sms:sent · sms:delivered · sms:failed gsmnode_sms_sent message_id, phone_numbers, status, error
sms:data-received gsmnode_sms_data_received the above plus data_payload, data_port
mms:received · mms:downloaded gsmnode_mms_received the above plus subject, attachments
call:received · call:sent · call:failed gsmnode_call_received call_id, phone_number, direction, status, started_at, duration

Nothing has to be registered by hand at either end. Home Assistant mints a webhook of its own when the entry is created, and the integration subscribes that URL with the API Server for exactly the ticked events — unticking removes the subscription again, and deleting the integration unsubscribes it entirely. Only subscriptions carrying this Home Assistant's webhook id are touched, so anything you registered yourself in the Web App is left alone.

To use one, build an automation (Settings → Automations → Create), choose the Event trigger, and give it the event type from the table — the options form prints the exact names your current selection produces. Every event also carries device_id and created_at, and the payload is flattened to the top level, so a template reads trigger.event.data.phone_number directly:

# What the UI editor produces — you never have to write this by hand.
triggers:
  - trigger: event
    event_type: gsmnode_sms_received
actions:
  - action: persistent_notification.create
    data:
      title: "SMS from {{ trigger.event.data.phone_number }}"
      message: "{{ trigger.event.data.message }}"

Two things to know:

  • The gateway has to be able to reach Home Assistant. The address used is the one Home Assistant knows itself by (internal first, external as a fallback); if the gateway needs a different one, set it in the same form.
  • With end-to-end encryption on, phone_number and message arrive as gsmenc:v1:… ciphertext. Home Assistant has no passphrase and cannot read them — this integration does not do E2E.

Notification targets

A notify entity is called with a message and nothing else — notify.send_message has no field for a recipient, a phone or a SIM. So each notification target decides all of that up front, and you add one per combination you need. They appear on the integration's page under Add notification target:

Field What it does
Name names the entity — "Alerts" gives notify.alerts
Send as SMS, MMS, or a call that rings the recipients
Recipients every message to this target goes to all of them
Phone which gateway phone sends it, from a picker
SIM picked from the phone's actual SIMs — carrier and number, not a slot index
MMS subject used when nothing passes a title with the message
action: notify.send_message
target:
  entity_id: notify.alerts
data:
  message: "Washing machine finished"

The SIM is a second step, because what it can offer depends on the phone chosen in the first. The phones report their own SIMs on every heartbeat — slot, carrier, number — so the list reads SIM 1 (slot 0) — Orange · +48555111222 rather than asking for a slot index. Leave it empty for the phone's default SIM.

Two things make that list empty, and both fall back to typing a slot number: a phone that has not been granted the phone permission reports no SIMs at all, and a gateway that is unreachable has nothing to report. With no phone chosen, every phone's SIMs are offered, collapsed by slot — the stored value is the slot, so two phones' "SIM 1" is one choice that names both.

Each target gets its own device and entity, so an alert can text the family from one SIM while an escalation rings the on-call phone from the other. Editing a target — or deleting it — is on the same page. An MMS target accepts a title: and sends it as the subject.

For anything that has to choose per message, use gsmnode.send, which takes all the same fields as arguments.

Replaced the single notify.gsmnode entity of 3.0.0, and the YAML platform before it. If you had either, add a target here instead.

How it works

  • On first use it logs in (POST /api/auth/login) and caches the token; on a 401 it re-logs in once and retries, and gives up into a reauth prompt if that fails too.
  • send_smsPOST /api/messages; callPOST /api/calls.
  • Every 30s one coordinator polls GET /api/health and GET /api/devices and feeds both the gateway sensor and the per-phone ones. An unreachable server turns the sensor off rather than making it unavailable — reporting that is the sensor's whole job.
  • All HTTP uses Home Assistant's shared aiohttp session (fully async), with a 15s timeout per request.
  • The sidebar item is an iframe panel (frontend.async_register_built_in_panel) registered when the entry loads and removed when it unloads. Nothing is proxied through Home Assistant — the browser fetches the page straight from the gateway.
  • gsmnode.send posts to /api/messages for an SMS or MMS and /api/calls for a call; the SIM slot is 0-based on the wire in both. Choosing a SIM for a call needs an API Server and Phone Agent from this repo at or after the same commit — older ones accept the field and ignore it.
  • MMS attachments are read in an executor, checked against allowlist_external_dirs, capped at 1 MB each, and base64'd into the request.
  • The SIM list is built from GET /api/devices, which the coordinator already polls. The logic lives in sims.py, kept free of Home Assistant imports so tests/test_sims.py can exercise it with plain Python: python -m unittest discover tests.
  • Incoming events use Home Assistant's own webhook component. The id is minted once per entry and stored with it, the URL is GET-proof (POST only), and each delivery is re-fired on the bus. Subscriptions on the gateway are reconciled on every load, so the set on the server always matches the ticks in the form.

Security

Worth understanding before this carries anything that matters.

Use https if the gateway is not on a network you trust. Over plain http the login sends your gateway password in the request body, the token that follows rides in a header, and message text and recipient numbers are readable to anything on the path. The client uses Home Assistant's shared session, which validates certificates — so https gives real protection, and a self-signed certificate will be refused with no override. The API Server speaks http only, so https means putting a reverse proxy in front of it.

Incoming deliveries are signed. The webhook URL alone is a weak credential: the gateway knows it, stores it, and anything that learns it could otherwise post a forged sms:received that automations would act on. So the integration mints a secret per entry, hands it to the gateway at registration, and the gateway signs every delivery:

X-GsmNode-Timestamp: 1700000000
X-GsmNode-Signature: sha256=<hmac-sha256 of "<timestamp>.<body>">

Anything unsigned, wrongly signed, or more than five minutes old is answered 401 and never reaches the bus. The timestamp is inside the MAC, so a captured delivery cannot be replayed by rewriting its header. When the gateway reaches Home Assistant on the local network, the webhook is additionally registered local_only, so a forged request from outside is refused before any of this.

What is still worth knowing:

  • The gateway password is stored in the config entry, which Home Assistant writes unencrypted to .storage/core.config_entries. Filesystem or backup access to the Home Assistant host gives up that password. The session token itself is only ever held in memory.
  • Messages sent from here are not end-to-end encrypted, so the API Server and its database can read them, unlike ones composed in the Web App.
  • The sidebar panel loads the gateway straight into your browser, so http there is your browser's plaintext, and the gateway login is typed into that frame.

Brand assets

brand/ holds what Home Assistant shows for the integration itself, in the layout and sizes its brands repository requires — a local brand/ folder wins over the CDN, so nothing has to be submitted upstream:

File Source (design kit) Size
icon.png app-icon-512.png, scaled 256×256
icon@2x.png app-icon-512.png 512×512
logo.png lockup-horizontal-color.png 936×224
dark_logo.png lockup-horizontal-white.png 936×224

The icon is the app tile — signal-green with white arrows — which stays legible on either theme, so no dark_icon.png is needed. The wordmark does need both: logo.png sets "gsm" in ink, dark_logo.png in white. There is no logo@2x.png; the widest lockup in the kit is 936×224, and its short side would have to be upscaled to reach the hDPI range.

Notes / limitations

  • The API Server must be reachable from the Home Assistant host.
  • No external dependencies (requirements: []).
  • SIM slots are 0-based here and everywhere else in gsmnode: slot 0 is the first SIM. (A phone's sensor lists its slots and carriers in its attributes.)
  • Only plain SMS and calls are wired up. The API Server also accepts MMS (type: mms with subject/attachments) and data SMS (type: data); those have no service here yet.
  • End-to-end encryption is not supported. Messages sent from here are plaintext and flagged as such, so a Phone Agent with a passphrase still sends them fine — but the API Server can read them, unlike ones composed in the Web App. Incoming SMS relayed to a HA webhook likewise arrive as ciphertext if the Phone Agent encrypted them.

Where this comes from

The integration is one surface of gsmnode, which also has an API Server, a Web App, and two Android apps — a Phone Agent that does the sending and a Phone App that manages the gateway. Those live in the project's own repository, freefall-dev/gsmnode; this one holds the Home Assistant integration alone, because HACS requires custom_components/<domain>/ at the root of the repository it installs from.

It is published by splitting that folder out, so the history here is the real history of the integration and not a squash. Issues and pull requests are welcome here; changes are merged upstream and land back on the next release.

License

Apache License 2.0.

The gsmnode name and marks are excluded from that grant — see NOTICE and section 6 of the License. Fork it freely; just don't ship the fork under this name and logo.