Skip to content
1 change: 1 addition & 0 deletions .changelog/pr-2007.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement MQTT Extended Status Phase 1a - by @IsmaelMartinez (#2007)
5 changes: 5 additions & 0 deletions app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
const path = require("node:path");
const CustomBackground = require("./customBackground");
const { MQTTClient } = require("./mqtt");
const MQTTMediaStatusService = require("./mqtt/mediaStatusService");
const GraphApiClient = require("./graphApi");
const { registerGraphApiHandlers } = require("./graphApi/ipcHandlers");
const { validateIpcChannel, allowedChannels } = require("./security/ipcValidator");
Expand Down Expand Up @@ -44,6 +45,7 @@ CommandLineManager.addSwitchesAfterConfigLoad(config);

let userStatus = -1;
let mqttClient = null;
let mqttMediaStatusService = null;
let graphApiClient = null;

let player;
Expand Down Expand Up @@ -293,6 +295,9 @@ async function handleAppReady() {
});

mqttClient.initialize();

mqttMediaStatusService = new MQTTMediaStatusService(mqttClient, config);
mqttMediaStatusService.initialize();
}

// Load menu-toggleable settings from persistent store
Expand Down
11 changes: 9 additions & 2 deletions app/mainAppWindow/browserWindowManager.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const {
app,
BrowserWindow,
ipcMain,
session,
Expand Down Expand Up @@ -246,18 +247,24 @@ class BrowserWindowManager {
assignOnCallConnectedHandler() {
return async (e) => {
this.isOnCall = true;
return this.config.screenLockInhibitionMethod === "Electron"
const result = this.config.screenLockInhibitionMethod === "Electron"
? this.disableScreenLockElectron()
: this.disableScreenLockWakeLockSentinel();

app.emit('teams-call-connected');
return result;
};
}

assignOnCallDisconnectedHandler() {
return async (e) => {
this.isOnCall = false;
return this.config.screenLockInhibitionMethod === "Electron"
const result = this.config.screenLockInhibitionMethod === "Electron"
? this.enableScreenLockElectron()
: this.enableScreenLockWakeLockSentinel();

app.emit('teams-call-disconnected');
return result;
};
}
}
Expand Down
42 changes: 42 additions & 0 deletions app/mqtt/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,16 @@ class MQTTClient extends EventEmitter {
}

try {
const connectionTopic = `${this.config.topicPrefix}/connected`;

const options = {
clientId: this.config.clientId,
will: {
topic: connectionTopic,
payload: 'false',
qos: 0,
retain: true
}
};

if (this.config.username) {
Expand All @@ -64,6 +72,8 @@ class MQTTClient extends EventEmitter {
this.isConnected = true;
console.info('[MQTT] Successfully connected to broker');

this.client.publish(connectionTopic, 'true', { retain: true });

// Subscribe to command topic for receiving action commands (if configured)
if (this.config.commandTopic) {
const commandTopic = `${this.config.topicPrefix}/${this.config.commandTopic}`;
Expand Down Expand Up @@ -107,6 +117,36 @@ class MQTTClient extends EventEmitter {
}
}

/**
* Generic publish method for publishing any payload to MQTT
* @param {string} topic - Full MQTT topic path
* @param {string|object} payload - Payload to publish (will be converted to string)
* @param {object} options - MQTT publish options
* @param {boolean} options.retain - Whether to retain the message (default: true)
* @param {number} options.qos - Quality of Service level (default: 0)
*/
async publish(topic, payload, options = {}) {
if (!this.isConnected || !this.client) {
console.debug('[MQTT] Not connected, skipping publish');
return;
}

const payloadString = typeof payload === 'object'
? JSON.stringify(payload)
: String(payload);

try {
await this.client.publish(topic, payloadString, {
retain: options.retain ?? true,
qos: options.qos ?? 0
});

console.debug(`[MQTT] Published to ${topic}: ${payloadString.substring(0, 100)}`);
} catch (error) {
console.error(`[MQTT] Failed to publish to ${topic}:`, error);
}
}

/**
* Publish Teams status to MQTT topic
* @param {number|string} status - Teams status code
Expand Down Expand Up @@ -195,6 +235,8 @@ class MQTTClient extends EventEmitter {
if (this.client) {
console.debug('[MQTT] Disconnecting from broker');
try {
const connectionTopic = `${this.config.topicPrefix}/connected`;
await this.client.publish(connectionTopic, 'false', { retain: true });
await this.client.end(false);
} catch (error) {
console.error('[MQTT] Error disconnecting:', error);
Expand Down
60 changes: 60 additions & 0 deletions app/mqtt/mediaStatusService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { app, ipcMain } = require('electron');

/**
* MQTT Media Status Service
*
* Bridges IPC events from renderer process (WebRTC monitoring, call state)
* to MQTT broker for home automation integration.
*
* Publishes to topics:
* - {topicPrefix}/camera - Camera on/off state
* - {topicPrefix}/microphone - Microphone on/off state
* - {topicPrefix}/in-call - Active call state
*/
class MQTTMediaStatusService {
#mqttClient;
#topicPrefix;

constructor(mqttClient, config) {
this.#mqttClient = mqttClient;
this.#topicPrefix = config.mqtt.topicPrefix;
}

initialize() {
// Publish MQTT status when camera state changes
ipcMain.on('camera-state-changed', this.#handleCameraChanged.bind(this));
// Publish MQTT status when microphone state changes
ipcMain.on('microphone-state-changed', this.#handleMicrophoneChanged.bind(this));

app.on('teams-call-connected', this.#handleCallConnected.bind(this));
app.on('teams-call-disconnected', this.#handleCallDisconnected.bind(this));

console.info('[MQTTMediaStatusService] Initialized');
}

async #handleCallConnected() {
const topic = `${this.#topicPrefix}/in-call`;
await this.#mqttClient.publish(topic, 'true', { retain: true });
console.debug('[MQTTMediaStatusService] Call connected, published to', topic);
}

async #handleCallDisconnected() {
const topic = `${this.#topicPrefix}/in-call`;
await this.#mqttClient.publish(topic, 'false', { retain: true });
console.debug('[MQTTMediaStatusService] Call disconnected, published to', topic);
}

async #handleCameraChanged(event, enabled) {
const topic = `${this.#topicPrefix}/camera`;
await this.#mqttClient.publish(topic, String(enabled), { retain: true });
console.debug('[MQTTMediaStatusService] Camera state changed to', enabled, 'published to', topic);
}

async #handleMicrophoneChanged(event, enabled) {
const topic = `${this.#topicPrefix}/microphone`;
await this.#mqttClient.publish(topic, String(enabled), { retain: true });
console.debug('[MQTTMediaStatusService] Microphone state changed to', enabled, 'published to', topic);
}
}

module.exports = MQTTMediaStatusService;
4 changes: 4 additions & 0 deletions app/security/ipcValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const allowedChannels = new Set([
'incoming-call-action',
'call-connected',
'call-disconnected',

// Media status (camera/microphone)
'camera-state-changed',
'microphone-state-changed',

// Authentication and forms
'submitForm',
Expand Down
18 changes: 17 additions & 1 deletion docs-site/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,24 @@ Place your `config.json` file in the appropriate location based on your installa
}
```

**Published Topics:**

When MQTT is enabled, the following topics are automatically published:

| Topic | Payload | Description |
|-------|---------|-------------|
| `\{topicPrefix\}/connected` | `"true"` or `"false"` | App connection state (uses MQTT Last Will) |
| `\{topicPrefix\}/status` | JSON object | User presence status (Available, Busy, DND, Away, BRB) |
| `\{topicPrefix\}/in-call` | `"true"` or `"false"` | Active call state (connected/disconnected) |
| `\{topicPrefix\}/camera` | `"true"` or `"false"` | Camera on/off state (Phase 2) |
| `\{topicPrefix\}/microphone` | `"true"` or `"false"` | Microphone on/off state (Phase 2) |

All topics use retained messages by default, ensuring subscribers receive the last known state immediately upon connecting.

**Connection State:** The `connected` topic uses MQTT Last Will and Testament (LWT). If the app crashes or loses network connectivity, the broker automatically publishes `"false"`, allowing home automation to detect and handle stale state.

> [!NOTE]
> By default, MQTT operates in **status-only mode** (publishes status to `{topicPrefix}/{statusTopic}` but doesn't receive commands). To enable **bidirectional mode**, set `commandTopic` to a topic name like `"command"`. Commands will then be received on `{topicPrefix}/{commandTopic}`. See the **[MQTT Integration Guide](mqtt-integration.md)** for complete documentation, command examples, home automation, and troubleshooting.
> By default, MQTT operates in **status-only mode** (publishes status to `\{topicPrefix\}/\{statusTopic\}` but doesn't receive commands). To enable **bidirectional mode**, set `commandTopic` to a topic name like `"command"`. Commands will then be received on `\{topicPrefix\}/\{commandTopic\}`. See the **[MQTT Integration Guide](mqtt-integration.md)** for complete documentation, command examples, home automation, and troubleshooting.

### Microsoft Graph API

Expand Down
33 changes: 20 additions & 13 deletions docs-site/docs/development/ipc-api-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Note**: This file is auto-generated by `scripts/generateIpcDocs.js`. Do not edit manually.
>
> **Last Generated**: 2025-11-21T20:31:13.353Z
> **Last Generated**: 2025-11-30T09:23:24.414Z

## Overview

Expand All @@ -11,7 +11,7 @@ This document lists all IPC (Inter-Process Communication) channels registered in
- **Request/Response** channels use `ipcMain.handle()` and expect a return value
- **Event** channels use `ipcMain.on()` for fire-and-forget notifications

**Total Channels**: 38
**Total Channels**: 40

---

Expand All @@ -31,14 +31,14 @@ This document lists all IPC (Inter-Process Communication) channels registered in

| Channel | Type | Description | Location |
|---------|------|-------------|----------|
| `config-file-changed` | Event | Restart application when configuration file changes | [`app/index.js:138`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L138) |
| `get-app-version` | Request/Response | Get application version number | [`app/index.js:164`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L164) |
| `get-config` | Request/Response | Get current application configuration | [`app/index.js:140`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L140) |
| `get-navigation-state` | Request/Response | Get current navigation state (can go back/forward) | [`app/index.js:187`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L187) |
| `navigate-back` | Event | Navigate back in browser history | [`app/index.js:169`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L169) |
| `navigate-forward` | Event | Navigate forward in browser history | [`app/index.js:178`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L178) |
| `set-badge-count` | Request/Response | Set application badge count (dock/taskbar notification) | [`app/index.js:162`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L162) |
| `user-status-changed` | Request/Response | Handle user status changes from Teams (e.g., Available, Busy, Away) | [`app/index.js:160`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L160) |
| `config-file-changed` | Event | Restart application when configuration file changes | [`app/index.js:140`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L140) |
| `get-app-version` | Request/Response | Get application version number | [`app/index.js:166`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L166) |
| `get-config` | Request/Response | Get current application configuration | [`app/index.js:142`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L142) |
| `get-navigation-state` | Request/Response | Get current navigation state (can go back/forward) | [`app/index.js:189`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L189) |
| `navigate-back` | Event | Navigate back in browser history | [`app/index.js:171`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L171) |
| `navigate-forward` | Event | Navigate forward in browser history | [`app/index.js:180`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L180) |
| `set-badge-count` | Request/Response | Set application badge count (dock/taskbar notification) | [`app/index.js:164`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L164) |
| `user-status-changed` | Request/Response | Handle user status changes from Teams (e.g., Available, Busy, Away) | [`app/index.js:162`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/index.js#L162) |

## Custom Background

Expand All @@ -62,9 +62,9 @@ This document lists all IPC (Inter-Process Communication) channels registered in

| Channel | Type | Description | Location |
|---------|------|-------------|----------|
| `call-connected` | Request/Response | Notify when a call is connected | [`app/mainAppWindow/browserWindowManager.js:150`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mainAppWindow/browserWindowManager.js#L150) |
| `call-disconnected` | Request/Response | Notify when a call is disconnected | [`app/mainAppWindow/browserWindowManager.js:152`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mainAppWindow/browserWindowManager.js#L152) |
| `select-source` | Event | Handle screen sharing source selection from user | [`app/mainAppWindow/browserWindowManager.js:135`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mainAppWindow/browserWindowManager.js#L135) |
| `call-connected` | Request/Response | Notify when a call is connected | [`app/mainAppWindow/browserWindowManager.js:151`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mainAppWindow/browserWindowManager.js#L151) |
| `call-disconnected` | Request/Response | Notify when a call is disconnected | [`app/mainAppWindow/browserWindowManager.js:153`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mainAppWindow/browserWindowManager.js#L153) |
| `select-source` | Event | Handle screen sharing source selection from user | [`app/mainAppWindow/browserWindowManager.js:136`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mainAppWindow/browserWindowManager.js#L136) |

## Menus & Tray

Expand Down Expand Up @@ -96,6 +96,13 @@ This document lists all IPC (Inter-Process Communication) channels registered in
| `notification-show-toast` | Event | Display custom in-app toast notification in bottom-right corner | [`app/notificationSystem/index.js:17`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/notificationSystem/index.js#L17) |
| `notification-toast-click` | Event | Handle toast clicks - close the window and focus main window | [`app/notificationSystem/index.js:19`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/notificationSystem/index.js#L19) |

## Other

| Channel | Type | Description | Location |
|---------|------|-------------|----------|
| `camera-state-changed` | Event | Publish MQTT status when camera state changes | [`app/mqtt/mediaStatusService.js:25`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mqtt/mediaStatusService.js#L25) |
| `microphone-state-changed` | Event | Publish MQTT status when microphone state changes | [`app/mqtt/mediaStatusService.js:27`](https://github.com/IsmaelMartinez/teams-for-linux/blob/develop/app/mqtt/mediaStatusService.js#L27) |

## Partitions & Zoom

| Channel | Type | Description | Location |
Expand Down
Loading
Loading