No description
  • JavaScript 98.3%
  • VBScript 0.9%
  • Batchfile 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ben T 443769bfad Fix Mercury device registration (WDM now requires model)
Webex WDM started rejecting device creation with 400 "Missing Model". Send model and
localizedModel, and log the response body on device list/create failures.
2026-10-01 07:07:12 -07:00
.gitattributes Webex presence + status bridge driven by Home Assistant 2026-10-01 06:57:25 -07:00
.gitignore Webex presence + status bridge driven by Home Assistant 2026-10-01 06:57:25 -07:00
config.example.json Webex presence + status bridge driven by Home Assistant 2026-10-01 06:57:25 -07:00
LICENSE Add the Do-Not-Disturb License 2026-10-01 07:04:56 -07:00
presence-bridge.mjs Fix Mercury device registration (WDM now requires model) 2026-10-01 07:07:12 -07:00
README.md Add the Do-Not-Disturb License 2026-10-01 07:04:56 -07:00
run-bridge.cmd Webex presence + status bridge driven by Home Assistant 2026-10-01 06:57:25 -07:00
run-bridge.vbs Webex presence + status bridge driven by Home Assistant 2026-10-01 06:57:25 -07:00

Webex Presence + Status (Home Assistant → Webex)

One small Windows service that keeps your Webex availability and custom status in sync with Home Assistant, watches its own health, and records your presence over time. Runs hidden at logon, restarts itself if it dies. Node 18+, no npm dependencies.

Personal project, not affiliated with Cisco/Webex. It talks to some undocumented Webex endpoints (presence "apheleia" events and the Mercury websocket) that may change without notice.

Presence (availability)

Home Assistant state Webex availability Webex shows
personEntity = home (and houseModeEntity ≠ asleepState) active Active (green)
anything else, or house mode = asleep dnd Do Not Disturb / Busy (red)

While away/asleep the custom status is cleared too.

Status ticker (custom status)

The 75-char custom status line, chosen in priority order:

  1. Away/asleep → nothing (the presence half clears it).
  2. In a call/meeting/presenting → nothing (the YouTube Music Webex plugin clears it during calls).
  3. A track is playing (status starts with 🎵) → always show the info line and prepend the track: 🎵 <track> · <info>. If the track is too long, it is shortened with … so the info line always survives.
  4. Otherwise → the info line: 🏠 Springfield, IL · ⛅89° · ↑6:18a↓6:23p

Info line parts:

  • City from geoEntity (e.g. the HA companion app's geocoded location sensor — follows you when you travel).
  • Weather from weatherEntity (condition emoji + temperature).
  • Sun from sunEntity, rendered in the current location's timezone: the phone's own timezone sensor (tzEntity) if it reports one, else a coordinate→IANA lookup (cached), else the timeZone fallback.

If you use a YouTube Music Webex plugin that writes 🎵 statuses, it keeps ownership of that line; this service only fills the gaps and enhances the track line when it fits.

Watchdog

Every hour the service checks itself and raises (or clears) a Home Assistant notification:

  • Home Assistant reachable,
  • Webex presence API responding,
  • a successful Webex write within watchdogMaxSilenceMinutes (default 120).

Notifications go to persistent_notification and notify.<notifyService> (both configurable).

Alerts (Webex messages → lights)

A real-time Mercury websocket listener (the same transport the Webex client uses) watches your incoming messages. When someone senior messages you, it flashes the lights.

  • Dynamic allowlist by job title: the sender's title (from the Webex directory) is matched against alertTitleRegex — by default Manager / Lead / Director / Staff Engineer / Engineer III / President (word-boundary match, so "Network Engineer II" and "Leadership Coach" do not match).
  • Triggers on 1:1 direct messages only by default (alertOnDMs). Set alertOnMentions: true to also trigger on @mentions in group spaces.
  • Your own messages and bots are ignored.
  • Action: lightEntity goes full lightColor for lightFlashSeconds, then the previous on/off/colour is restored. A global alertCooldownSeconds prevents repeat flashing.

Titles and room types are cached for titleCacheHours. The listener registers a Webex device named mercuryDeviceName (reused across restarts) and reconnects automatically.

Analytics

Every analyticsIntervalSeconds (default 60) the composed presence is sampled:

  • presence-history.jsonl — one line per state transition (t, status, category, from).
  • presence-today.json — seconds spent in each category today; rolls over at midnight and logs a daily summary to presence-bridge.log.

Setup

  1. Clone the repo somewhere permanent and install Node 18+.

  2. Config: copy config.example.json to config.json and fill in:

    • haUrl and haToken — a Home Assistant long-lived access token (HA → Profile → Security → Long-lived access tokens).
    • The entity IDs (personEntity, houseModeEntity, geoEntity, tzEntity, weatherEntity, notifyService, lightEntity) to match your Home Assistant. config.json is git-ignored — never commit it.
  3. Webex credentials: the service does not do its own OAuth login. It looks for a Webex OAuth access/refresh token (and client id/secret for refreshing) in, in order:

    • tokens.json next to the script (its own copy, written after a refresh),
    • the YouTube Music desktop app's Webex plugin config (%APPDATA%\YouTube Music\config.json, plugins.webex),
    • ~/webex-messaging-mcp-server/.webex-tokens.json and that folder's .env (WEBEX_CLIENT_ID / WEBEX_CLIENT_SECRET).

    The simplest route for a fresh install is to create a Webex integration at developer.webex.com, complete an OAuth flow once, and write the result to tokens.json (access_token, refresh_token, expires_at in ms, client_id, client_secret).

  4. Try it in a console: node presence-bridge.mjs and watch presence-bridge.log.

  5. Run at logon: copy run-bridge.vbs into your Startup folder (shell:startup), and edit BRIDGE_DIR in the copy to point at your clone. If Node isn't at C:\Program Files\nodejs\node.exe, edit run-bridge.cmd too.

How it runs

  • run-bridge.vbs (copied to the Startup folder) launches the supervisor hidden at logon.
  • run-bridge.cmd is a supervisor that restarts presence-bridge.mjs if it exits.
  • presence-bridge.mjs polls HA every 60 s (presence), 20 s (ticker), 60 s (analytics), 1 h (watchdog).

Files

File Purpose
presence-bridge.mjs The service.
config.example.json Template for config.json.
config.json (ignored) HA URL + long-lived token, entities, and tuning.
tokens.json (ignored) Created on first refresh; the service's own Webex token copy.
presence-bridge.log (ignored) Append-only activity log.
presence-history.jsonl (ignored) Presence transition log.
presence-today.json (ignored) Per-day time-in-state totals.
run-bridge.cmd / run-bridge.vbs Hidden supervisor + logon launcher.

Tuning (config.json)

Key Default Meaning
pollSeconds 60 Presence poll interval.
awayTtlSeconds 900 DND validity; re-asserted every poll while away.
activeTtlSeconds / activeReassertSeconds 3600 / 300 Active event TTL and re-assert cadence.
clearCustomStatusWhenAway true Clear 🎵 … while away/asleep.
tickerEnabled / tickerPollSeconds true / 20 Info-line ticker on/off and cadence.
tickerTtlSeconds / tickerReassertSeconds 900 / 600 Status TTL and refresh cadence.
timeZone / tzEntity / tzCacheHours UTC / sensor.phone_current_time_zone / 6 Sun-time timezone: phone sensor → coordinate lookup → this fallback.
geoEntity / weatherEntity / sunEntity sensor.phone_geocoded_location / weather.forecast_home / sun.sun Sources.
personEntity / houseModeEntity / asleepState person.me / input_select.house_mode / Asleep Presence sources.
watchdogEnabled / watchdogIntervalSeconds / watchdogMaxSilenceMinutes true / 3600 / 120 Watchdog.
notifyService / notifyPersistent mobile_app_phone / true Where alerts go.
analyticsEnabled / analyticsIntervalSeconds true / 60 Presence sampling.
alertsEnabled / mercuryEnabled true / true Webex message → lights alerts.
alertTitleRegex \b(manager|lead|director|staff engineer|engineer iii|president)\b Titles that trigger.
alertOnDMs / alertOnMentions true / false What counts as directed at you.
lightEntity / lightColor / lightBrightness / lightFlashSeconds light.office / [255,0,0] / 255 / 15 Flash action.
alertCooldownSeconds / titleCacheHours 60 / 24 Rate limit and cache TTL.

Testing / forcing a state

Stop the running service first (Task Manager → the node.exe running presence-bridge.mjs and the cmd.exe supervisor), then from the repo folder:

$env:BRIDGE_FORCE='away'; node presence-bridge.mjs   # forces DND
$env:BRIDGE_FORCE='home'; node presence-bridge.mjs   # forces Active

(BRIDGE_FORCE only affects presence; the ticker still follows HA/Webex state.)

Disabling / removing

  • Temporarily: kill the presence-bridge.mjs node process and the cmd.exe supervisor.
  • Permanently: delete your copy of run-bridge.vbs from the Startup folder.

Notes / caveats

  • HA token: use a dedicated long-lived token so you can revoke it independently (HA → Profile → Security).
  • Webex token: if the service refreshes, it saves to tokens.json and mirrors into the MCP server's token file. Webex may rotate refresh tokens, so an independent refresh could require other apps sharing the same token (e.g. the YouTube Music plugin) to reconnect; it only refreshes when no valid access token exists.
  • Manual overrides: while home, a DND you set by hand is cleared within ~5 min.
  • If personEntity is unknown/unavailable, presence is left unchanged rather than flipping to DND.
  • Sun times use sun.sun's next rise/set; late in the day those roll to tomorrow's events.
  • Startup launchers must keep Windows (CRLF) line endings — LF-only .cmd files silently fail. .gitattributes enforces this on checkout.

License

The Do-Not-Disturb License — MIT with jokes. Do what you like; beverages appreciated.