← All Tutorials

Automated Softphone Deployment for Call Center Agents

Infrastructure & DevOps Intermediate 41 min read #16

Tutorial 16: Automated Softphone Deployment for Call Center Agents

tSIP / MicroSIP + PowerShell + Config Server


Table of Contents

  1. Introduction
  2. Architecture Overview
  3. Prerequisites
  4. Part 1: Softphone Selection and Configuration
  5. Part 2: Locked-Down tSIP Configuration (ViciPhone)
  6. Part 3: MicroSIP Configuration Template
  7. Part 4: Bulk Config Generation
  8. Part 5: Deployment Script (deploy-viciphone.ps1)
  9. Part 6: Watchdog Script
  10. Part 7: Uninstaller
  11. Part 8: Remote Deployment via WinRM
  12. Part 9: FastAPI Event Receiver
  13. Part 10: Testing Before Mass Rollout
  14. Part 11: Handling Updates
  15. Part 12: Monitoring Connection Quality
  16. Troubleshooting
  17. Summary

Introduction

If you manage a call center with more than a handful of agents, softphone deployment becomes one of those problems that looks trivial until it isn't.

A single agent's softphone is easy: download the installer, type in the SIP credentials, done. But when you have 40, 80, or 200 agents -- many of them on remote Windows desktops, some of them on Kamatera VMs, others on physical machines in the office -- that manual process becomes a full-time job. And it keeps coming back. Every new hire needs a phone. Every terminated agent needs their credentials removed. Every codec change or SIP server migration means touching every single machine.

The real problems show up at 2 AM when nobody is watching:

This tutorial walks through the complete system we built to solve all of this: a locked-down softphone deployment with automated installation, a watchdog that keeps it alive, bulk configuration generation, remote deployment over WinRM, and an event receiver that gives you real-time visibility into softphone state.

The total cost of the software involved is zero. tSIP is free. MicroSIP is free. PowerShell is built into Windows. The event receiver runs on any Linux box you already have.

Who This Is For


Architecture Overview

+------------------------------------------+
|           Config / Deploy Server          |
|           (Linux or Windows)              |
|                                           |
|  - Config templates (per extension)       |
|  - Deployment scripts                     |
|  - Softphone binaries (tSIP / MicroSIP)  |
|  - FastAPI event receiver (:8090)         |
+------------------+-----------------------+
                   |
          WinRM / PowerShell Remoting
                   |
     +-------------+-------------+
     |             |             |
+----+----+  +----+----+  +----+----+
| Agent 1 |  | Agent 2 |  | Agent N |
| Win 10  |  | Win 10  |  | Win 10  |
|         |  |         |  |         |
| tSIP    |  | tSIP    |  | tSIP    |
| locked  |  | locked  |  | locked  |
| watchdog|  | watchdog|  | watchdog|
+---------+  +---------+  +---------+
     |             |             |
     +------+------+------+------+
            |              |
     +------+------+ +----+-----+
     |  SIP Server | | ViciDial |
     |  (Asterisk) | |  Server  |
     +-------------+ +----------+

Flow:

  1. You generate per-extension config files from a template (one .ini per SIP extension).
  2. The deployment script copies the softphone binary + config + watchdog to each agent machine.
  3. The watchdog runs as a scheduled task, restarting the softphone if it crashes.
  4. The softphone UI is locked down -- agents cannot access settings, change codecs, or modify SIP credentials.
  5. Softphone events (registration, calls, errors) are posted to a central FastAPI receiver for monitoring.

Prerequisites

On the deployment/config server (Linux or Windows):

On each agent Windows machine:

Software to download:

SIP infrastructure:


Part 1: Softphone Selection and Configuration

tSIP vs MicroSIP: When to Use Which

Feature tSIP MicroSIP
Deployment model Portable (no installer) MSI/EXE installer
Config format JSON INI
UI lockdown Excellent (built-in) Requires registry hacks
Auto-answer Native support Native support
SRTP Supported Supported
Size ~5 MB ~7 MB
Updates Manual Auto-update available
Best for Locked kiosk/agent use Power users, flexible setups

Our recommendation: Use tSIP when you need full lockdown (agents should not touch anything). Use MicroSIP when agents need minimal control (volume, mute) or when you need an MSI for Group Policy deployment.

This tutorial covers both. The deployment scripts support either one.


Part 2: Locked-Down tSIP Configuration (ViciPhone)

tSIP stores its configuration in a JSON file (tSIP.json) in the same directory as the executable. This is what makes it perfect for portable, locked-down deployment: you control the config file, and the agent never sees a settings dialog.

tSIP Configuration Template

Save this as tSIP_template.json. The deployment script will substitute placeholders at generation time.

{
  "info": "ViciPhone locked config - DO NOT EDIT",
  "Accounts": [
    {
      "reg_server": "{{SIP_SERVER}}",
      "reg_user": "{{EXTENSION}}",
      "reg_password": "{{SIP_PASSWORD}}",
      "reg_expires": 120,
      "transport": "UDP",
      "auth_user": "{{EXTENSION}}",
      "display_name": "Agent {{EXTENSION}}",
      "stun_server": "",
      "outbound_proxy": "",
      "audio_codecs": [
        {
          "name": "{{PRIMARY_CODEC}}",
          "enabled": true
        },
        {
          "name": "{{SECONDARY_CODEC}}",
          "enabled": true
        },
        {
          "name": "GSM",
          "enabled": false
        },
        {
          "name": "speex/8000",
          "enabled": false
        },
        {
          "name": "G.722",
          "enabled": false
        },
        {
          "name": "opus/48000",
          "enabled": false
        }
      ],
      "ptime": 20,
      "answer_any": 0,
      "auto_answer": 1,
      "auto_answer_code": 200,
      "auto_answer_delay_ms": 0,
      "auto_answer_play_ring": 0
    }
  ],
  "uaConf": {
    "local_port": 0,
    "rtp_port_min": 10000,
    "rtp_port_max": 10100,
    "log_messages": 0,
    "srtp": "{{SRTP_MODE}}"
  },
  "frmMain": {
    "hide_settings": true,
    "hide_view": true,
    "hide_tools": true,
    "hide_help": false,
    "always_on_top": false,
    "start_minimized_to_tray": false,
    "show_when_answering": true,
    "show_when_making_call": false,
    "window_width": location,
    "window_height": 340,
    "no_taskbar_button": false,
    "no_tray_icon": false,
    "hide_mouse_cursor_on_button": false,
    "hide_main_menu": true,
    "hide_statusbar": false,
    "hide_dialpad": true,
    "hide_call_panel": false,
    "dial_combo_box_auto_complete": false
  },
  "frmContactPopup": {
    "show_on_incoming": false,
    "show_on_outgoing": false
  },
  "Logging": {
    "log_to_file": true,
    "log_flush": true,
    "max_file_size_kb": 1024,
    "log_rotate": 3
  },
  "Events": {
    "on_registration_state": "{{EVENT_URL}}/event/registration",
    "on_call_state": "{{EVENT_URL}}/event/call",
    "on_app_start": "",
    "on_app_end": ""
  }
}

Key Configuration Decisions Explained

Codec priority matters. If your SIP trunks use G.711 a-law (common in Europe, UK, and most of the world outside North America), set PRIMARY_CODEC to PCMA (a-law). If your trunks use u-law (North America), set it to PCMU. Getting this wrong means Asterisk transcodes every RTP stream, which wastes CPU and can degrade audio quality.

Auto-answer is essential for ViciDial. When ViciDial sends a call to an agent, the softphone must pick up automatically. Setting auto_answer: 1 with auto_answer_code: 200 and auto_answer_delay_ms: 0 ensures instant pickup with no agent interaction.

UI lockdown fields:

The agent sees only the call panel: who is calling, call duration, and hangup button. Nothing else.

SRTP modes: Set to "disabled" for internal-only calls, "optional" if some trunks support it, or "mandatory" if your security policy requires encrypted media. Note that SRTP requires TLS transport for the SIP signaling to be truly secure.


Part 3: MicroSIP Configuration Template

MicroSIP uses a standard Windows INI file format. Config is stored in %APPDATA%\MicroSIP\microsip.ini (installer version) or next to the executable (portable version).

MicroSIP INI Template

Save this as microsip_template.ini:

[Settings]
; === SIP Account ===
sipServer={{SIP_SERVER}}
sipUser={{EXTENSION}}
sipPassword={{SIP_PASSWORD}}
sipAuthID={{EXTENSION}}
sipDisplayName=Agent {{EXTENSION}}
sipTransport=udp
sipPort=5060
sipPublicAddr=
sipStunServer=
sipProxy=

; === Audio ===
audioCodecs={{PRIMARY_CODEC}} {{SECONDARY_CODEC}}
audioInputDevice=
audioOutputDevice=
audioRingDevice=
ringToneFile=
volumeRing=100
volumeOutput=100
volumeInput=100

; === Auto Answer ===
autoAnswer=1
autoAnswerDelay=0
autoAnswerPlayBeep=0

; === SRTP ===
enableSRTP={{SRTP_MODE}}

; === UI Lockdown ===
disableSettings=1
disableAccountEdit=1
disableDialpad=0
startMinimized=0
singleMode=1
silent=0

; === Network ===
localPort=0
rtpPortMin=10000
rtpPortMax=10100
enableSTUN=0
enableICE=0

; === Behavior ===
enableLog=1
logFile=viciphone.log
bringToFrontOnIncoming=1
enableAutostart=1

Locking Down MicroSIP Further with Registry

MicroSIP's built-in disableSettings=1 hides the settings button, but a determined agent can still find the config file. For tighter lockdown, apply these registry keys via the deployment script:

# Prevent MicroSIP settings access via registry
New-ItemProperty -Path "HKCU:\Software\MicroSIP" -Name "DisableUI" -Value 1 -PropertyType DWORD -Force
New-ItemProperty -Path "HKCU:\Software\MicroSIP" -Name "SettingsLocked" -Value 1 -PropertyType DWORD -Force

And set the config file to read-only:

Set-ItemProperty -Path "C:\ViciPhone\microsip.ini" -Name IsReadOnly -Value $true

Part 4: Bulk Config Generation

When you have 40 extensions (or 200), you do not write 40 config files by hand. This Python script reads a CSV of extensions and generates one config file per extension for either tSIP or MicroSIP.

Extension List (CSV)

Create extensions.csv:

extension,password,agent_name,machine_ip
1031,CHANGE_ME_PASS_1031,Agent Alpha,192.168.1.101
1032,CHANGE_ME_PASS_1032,Agent Bravo,192.168.1.102
1033,CHANGE_ME_PASS_1033,Agent Charlie,192.168.1.103
1034,CHANGE_ME_PASS_1034,Agent Delta,192.168.1.104
1035,CHANGE_ME_PASS_1035,Agent Echo,192.168.1.105

Bulk Generation Script

Save as generate_configs.py:

#!/usr/bin/env python3
"""
Bulk softphone config generator.
Reads extensions.csv and produces one config file per extension.
Supports tSIP (JSON) and MicroSIP (INI) output formats.

Usage:
    python3 generate_configs.py --format tsip --output ./configs/
    python3 generate_configs.py --format microsip --output ./configs/
"""

import csv
import json
import os
import argparse
import shutil
from pathlib import Path


# ============================================================
# CONFIGURATION - Edit these values for your environment
# ============================================================
SIP_SERVER = "sip.example.com"       # Your Asterisk/SIP server IP or hostname
PRIMARY_CODEC = "PCMA"               # PCMA = a-law (EU/UK), PCMU = u-law (NA)
SECONDARY_CODEC = "PCMU"             # Fallback codec
SRTP_MODE = "disabled"               # disabled | optional | mandatory
EVENT_URL = "http://monitor.example.com:8090"  # FastAPI event receiver URL
# ============================================================


def load_tsip_template():
    """Return the tSIP JSON config as a Python dict."""
    return {
        "info": "ViciPhone locked config - generated automatically",
        "Accounts": [
            {
                "reg_server": SIP_SERVER,
                "reg_user": "",
                "reg_password": "",
                "reg_expires": 120,
                "transport": "UDP",
                "auth_user": "",
                "display_name": "",
                "stun_server": "",
                "outbound_proxy": "",
                "audio_codecs": [
                    {"name": PRIMARY_CODEC, "enabled": True},
                    {"name": SECONDARY_CODEC, "enabled": True},
                    {"name": "GSM", "enabled": False},
                    {"name": "speex/8000", "enabled": False},
                    {"name": "G.722", "enabled": False},
                    {"name": "opus/48000", "enabled": False},
                ],
                "ptime": 20,
                "answer_any": 0,
                "auto_answer": 1,
                "auto_answer_code": 200,
                "auto_answer_delay_ms": 0,
                "auto_answer_play_ring": 0,
            }
        ],
        "uaConf": {
            "local_port": 0,
            "rtp_port_min": 10000,
            "rtp_port_max": 10100,
            "log_messages": 0,
            "srtp": SRTP_MODE,
        },
        "frmMain": {
            "hide_settings": True,
            "hide_view": True,
            "hide_tools": True,
            "hide_help": False,
            "always_on_top": False,
            "start_minimized_to_tray": False,
            "show_when_answering": True,
            "hide_main_menu": True,
            "hide_statusbar": False,
            "hide_dialpad": True,
        },
        "Logging": {
            "log_to_file": True,
            "log_flush": True,
            "max_file_size_kb": 1024,
            "log_rotate": 3,
        },
        "Events": {
            "on_registration_state": f"{EVENT_URL}/event/registration",
            "on_call_state": f"{EVENT_URL}/event/call",
        },
    }


def generate_tsip_config(extension, password, agent_name):
    """Generate a tSIP JSON config for one extension."""
    config = load_tsip_template()
    account = config["Accounts"][0]
    account["reg_user"] = extension
    account["auth_user"] = extension
    account["reg_password"] = password
    account["display_name"] = f"{agent_name} ({extension})"
    return json.dumps(config, indent=2)


def generate_microsip_config(extension, password, agent_name):
    """Generate a MicroSIP INI config for one extension."""
    return f"""[Settings]
sipServer={SIP_SERVER}
sipUser={extension}
sipPassword={password}
sipAuthID={extension}
sipDisplayName={agent_name} ({extension})
sipTransport=udp
sipPort=5060

audioCodecs={PRIMARY_CODEC} {SECONDARY_CODEC}
volumeRing=100
volumeOutput=100
volumeInput=100

autoAnswer=1
autoAnswerDelay=0
autoAnswerPlayBeep=0

enableSRTP={SRTP_MODE}

disableSettings=1
disableAccountEdit=1
singleMode=1

localPort=0
rtpPortMin=10000
rtpPortMax=10100
enableSTUN=0
enableICE=0

enableLog=1
logFile=viciphone_{extension}.log
bringToFrontOnIncoming=1
enableAutostart=1
"""


def main():
    parser = argparse.ArgumentParser(
        description="Generate per-extension softphone configs"
    )
    parser.add_argument(
        "--format",
        choices=["tsip", "microsip"],
        required=True,
        help="Output format: tsip (JSON) or microsip (INI)",
    )
    parser.add_argument(
        "--output",
        default="./configs",
        help="Output directory (default: ./configs)",
    )
    parser.add_argument(
        "--csv",
        default="extensions.csv",
        help="Path to extensions CSV file",
    )
    args = parser.parse_args()

    output_dir = Path(args.output)
    output_dir.mkdir(parents=True, exist_ok=True)

    generated = 0
    with open(args.csv, newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        for row in reader:
            ext = row["extension"]
            pwd = row["password"]
            name = row.get("agent_name", f"Agent {ext}")

            if args.format == "tsip":
                content = generate_tsip_config(ext, pwd, name)
                filename = f"tSIP_{ext}.json"
            else:
                content = generate_microsip_config(ext, pwd, name)
                filename = f"microsip_{ext}.ini"

            filepath = output_dir / filename
            filepath.write_text(content, encoding="utf-8")
            generated += 1
            print(f"  Generated: {filepath}")

    print(f"\nDone. {generated} config files written to {output_dir}/")
    print(f"Format: {args.format}")
    print(f"SIP server: {SIP_SERVER}")
    print(f"Codecs: {PRIMARY_CODEC} / {SECONDARY_CODEC}")


if __name__ == "__main__":
    main()

Usage

# Generate tSIP configs for all extensions
python3 generate_configs.py --format tsip --csv extensions.csv --output ./configs/tsip/

# Generate MicroSIP configs for all extensions
python3 generate_configs.py --format microsip --csv extensions.csv --output ./configs/microsip/

Output:

  Generated: configs/tsip/tSIP_1031.json
  Generated: configs/tsip/tSIP_1032.json
  Generated: configs/tsip/tSIP_1033.json
  ...
Done. 40 config files written to configs/tsip/
Format: tsip
SIP server: sip.example.com
Codecs: PCMA / PCMU

Part 5: Deployment Script

This is the core of the system. deploy-viciphone.ps1 handles everything: copying files, applying configuration, setting up auto-start, creating the desktop shortcut, installing the watchdog, and verifying the result.

Save as deploy-viciphone.ps1:

<#
.SYNOPSIS
    ViciPhone Deployment Script - Installs and configures tSIP/MicroSIP softphone.

.DESCRIPTION
    Deploys a locked-down softphone to an agent workstation:
    - Copies softphone binary to C:\ViciPhone\
    - Applies per-extension configuration
    - Creates desktop shortcut
    - Sets auto-start via registry (Run key)
    - Installs watchdog as a scheduled task
    - Verifies installation

.PARAMETER Extension
    The SIP extension number (e.g., 1031). Used to select the correct config file.

.PARAMETER SoftphoneType
    Which softphone to deploy: "tsip" or "microsip". Default: tsip.

.PARAMETER SourcePath
    UNC path or local path to the deployment package containing binaries and configs.

.PARAMETER Unattended
    Skip confirmation prompts. Use for mass deployment.

.EXAMPLE
    .\deploy-viciphone.ps1 -Extension 1031 -SoftphoneType tsip -SourcePath "\\fileserver\viciphone"
    .\deploy-viciphone.ps1 -Extension 1045 -Unattended
#>

param(
    [Parameter(Mandatory = $true)]
    [string]$Extension,

    [ValidateSet("tsip", "microsip")]
    [string]$SoftphoneType = "tsip",

    [string]$SourcePath = "\\DEPLOY_SERVER\viciphone",

    [switch]$Unattended
)

# ============================================================
# CONFIGURATION
# ============================================================
$InstallDir       = "C:\ViciPhone"
$WatchdogDir      = "C:\ViciPhone\watchdog"
$LogFile          = "C:\ViciPhone\deploy.log"
$ShortcutName     = "ViciPhone"
$TaskName         = "ViciPhone-Watchdog"
$RegistryRunKey   = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"

# Binary names
$TsipExe          = "tSIP.exe"
$MicrosipExe      = "microsip.exe"

# ============================================================
# FUNCTIONS
# ============================================================

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logLine = "[$timestamp] [$Level] $Message"
    Add-Content -Path $LogFile -Value $logLine -ErrorAction SilentlyContinue
    if ($Level -eq "ERROR") {
        Write-Host $logLine -ForegroundColor Red
    } elseif ($Level -eq "WARN") {
        Write-Host $logLine -ForegroundColor Yellow
    } else {
        Write-Host $logLine -ForegroundColor Green
    }
}

function Stop-ExistingSoftphone {
    Write-Log "Stopping any running softphone processes..."
    $processes = @("tSIP", "microsip")
    foreach ($proc in $processes) {
        $running = Get-Process -Name $proc -ErrorAction SilentlyContinue
        if ($running) {
            Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
            Start-Sleep -Seconds 2
            Write-Log "Stopped $proc (PID: $($running.Id))"
        }
    }
}

function Backup-ExistingInstall {
    if (Test-Path $InstallDir) {
        $backupDir = "${InstallDir}_backup_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
        Write-Log "Backing up existing install to $backupDir"
        Copy-Item -Path $InstallDir -Destination $backupDir -Recurse -Force
        return $backupDir
    }
    return $null
}

function Install-Softphone {
    # Create install directory
    if (-not (Test-Path $InstallDir)) {
        New-Item -Path $InstallDir -ItemType Directory -Force | Out-Null
        Write-Log "Created directory: $InstallDir"
    }

    if (-not (Test-Path $WatchdogDir)) {
        New-Item -Path $WatchdogDir -ItemType Directory -Force | Out-Null
    }

    # Copy binary
    if ($SoftphoneType -eq "tsip") {
        $srcBinary = Join-Path $SourcePath "bin\$TsipExe"
        $dstBinary = Join-Path $InstallDir $TsipExe
        $configSrc = Join-Path $SourcePath "configs\tsip\tSIP_$Extension.json"
        $configDst = Join-Path $InstallDir "tSIP.json"
    } else {
        $srcBinary = Join-Path $SourcePath "bin\$MicrosipExe"
        $dstBinary = Join-Path $InstallDir $MicrosipExe
        $configSrc = Join-Path $SourcePath "configs\microsip\microsip_$Extension.ini"
        $configDst = Join-Path $InstallDir "microsip.ini"
    }

    # Verify source files exist
    if (-not (Test-Path $srcBinary)) {
        Write-Log "Binary not found: $srcBinary" "ERROR"
        throw "Deployment failed: binary not found at $srcBinary"
    }
    if (-not (Test-Path $configSrc)) {
        Write-Log "Config not found: $configSrc" "ERROR"
        throw "Deployment failed: config for extension $Extension not found at $configSrc"
    }

    # Copy files
    Copy-Item -Path $srcBinary -Destination $dstBinary -Force
    Write-Log "Copied binary: $srcBinary -> $dstBinary"

    Copy-Item -Path $configSrc -Destination $configDst -Force
    Write-Log "Copied config: $configSrc -> $configDst"

    # Make config read-only to prevent agent tampering
    Set-ItemProperty -Path $configDst -Name IsReadOnly -Value $true
    Write-Log "Set config file to read-only"

    # Copy watchdog script
    $watchdogSrc = Join-Path $SourcePath "scripts\watchdog.ps1"
    $watchdogDst = Join-Path $WatchdogDir "watchdog.ps1"
    if (Test-Path $watchdogSrc) {
        Copy-Item -Path $watchdogSrc -Destination $watchdogDst -Force
        Write-Log "Copied watchdog script"
    } else {
        Write-Log "Watchdog script not found at $watchdogSrc -- skipping" "WARN"
    }
}

function Set-AutoStart {
    $exeName = if ($SoftphoneType -eq "tsip") { $TsipExe } else { $MicrosipExe }
    $exePath = Join-Path $InstallDir $exeName

    # Registry Run key for current user
    Set-ItemProperty -Path $RegistryRunKey -Name $ShortcutName -Value "`"$exePath`""
    Write-Log "Auto-start set via registry Run key"
}

function New-DesktopShortcut {
    $desktopPath = [Environment]::GetFolderPath("Desktop")
    $shortcutPath = Join-Path $desktopPath "$ShortcutName.lnk"

    $exeName = if ($SoftphoneType -eq "tsip") { $TsipExe } else { $MicrosipExe }
    $exePath = Join-Path $InstallDir $exeName

    $shell = New-Object -ComObject WScript.Shell
    $shortcut = $shell.CreateShortcut($shortcutPath)
    $shortcut.TargetPath = $exePath
    $shortcut.WorkingDirectory = $InstallDir
    $shortcut.Description = "ViciPhone - Extension $Extension"
    $shortcut.WindowStyle = 1  # Normal window
    $shortcut.Save()

    Write-Log "Desktop shortcut created: $shortcutPath"
}

function Install-Watchdog {
    $watchdogScript = Join-Path $WatchdogDir "watchdog.ps1"

    if (-not (Test-Path $watchdogScript)) {
        Write-Log "Watchdog script not present, skipping scheduled task" "WARN"
        return
    }

    # Remove existing task if present
    $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    if ($existingTask) {
        Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
        Write-Log "Removed existing watchdog task"
    }

    # Create scheduled task that runs every 2 minutes
    $action = New-ScheduledTaskAction `
        -Execute "powershell.exe" `
        -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$watchdogScript`""

    $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
        -RepetitionInterval (New-TimeSpan -Minutes 2) `
        -RepetitionDuration (New-TimeSpan -Days 9999)

    $settings = New-ScheduledTaskSettingsSet `
        -AllowStartIfOnBatteries `
        -DontStopIfGoingOnBatteries `
        -StartWhenAvailable `
        -RestartCount 3 `
        -RestartInterval (New-TimeSpan -Minutes 1)

    Register-ScheduledTask `
        -TaskName $TaskName `
        -Action $action `
        -Trigger $trigger `
        -Settings $settings `
        -Description "Monitors ViciPhone and restarts if crashed" `
        -RunLevel Highest

    Write-Log "Watchdog scheduled task installed (runs every 2 minutes)"
}

function Test-Installation {
    Write-Log "Verifying installation..."
    $errors = @()

    $exeName = if ($SoftphoneType -eq "tsip") { $TsipExe } else { $MicrosipExe }
    $exePath = Join-Path $InstallDir $exeName

    # Check binary exists
    if (-not (Test-Path $exePath)) {
        $errors += "Binary not found: $exePath"
    }

    # Check config exists
    $configName = if ($SoftphoneType -eq "tsip") { "tSIP.json" } else { "microsip.ini" }
    $configPath = Join-Path $InstallDir $configName
    if (-not (Test-Path $configPath)) {
        $errors += "Config not found: $configPath"
    }

    # Check auto-start registry
    $regValue = Get-ItemProperty -Path $RegistryRunKey -Name $ShortcutName -ErrorAction SilentlyContinue
    if (-not $regValue) {
        $errors += "Auto-start registry key not set"
    }

    # Check watchdog task
    $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    if (-not $task) {
        $errors += "Watchdog scheduled task not found"
    }

    # Check shortcut
    $desktopPath = [Environment]::GetFolderPath("Desktop")
    $shortcutPath = Join-Path $desktopPath "$ShortcutName.lnk"
    if (-not (Test-Path $shortcutPath)) {
        $errors += "Desktop shortcut not found"
    }

    if ($errors.Count -gt 0) {
        foreach ($err in $errors) {
            Write-Log $err "ERROR"
        }
        return $false
    }

    Write-Log "All verification checks passed"
    return $true
}

# ============================================================
# MAIN EXECUTION
# ============================================================

Write-Log "=========================================="
Write-Log "ViciPhone Deployment Starting"
Write-Log "Extension: $Extension"
Write-Log "Type: $SoftphoneType"
Write-Log "Source: $SourcePath"
Write-Log "=========================================="

if (-not $Unattended) {
    $confirm = Read-Host "Deploy ViciPhone for extension $Extension ($SoftphoneType)? [Y/n]"
    if ($confirm -eq "n") {
        Write-Log "Deployment cancelled by user"
        exit 0
    }
}

try {
    # Step 1: Stop existing softphone
    Stop-ExistingSoftphone

    # Step 2: Backup existing install
    $backupDir = Backup-ExistingInstall

    # Step 3: Install files
    Install-Softphone

    # Step 4: Set auto-start
    Set-AutoStart

    # Step 5: Create desktop shortcut
    New-DesktopShortcut

    # Step 6: Install watchdog
    Install-Watchdog

    # Step 7: Verify
    $verified = Test-Installation

    if ($verified) {
        Write-Log "=========================================="
        Write-Log "DEPLOYMENT SUCCESSFUL"
        Write-Log "Extension: $Extension"
        Write-Log "Install path: $InstallDir"
        Write-Log "=========================================="

        # Start the softphone
        $exeName = if ($SoftphoneType -eq "tsip") { $TsipExe } else { $MicrosipExe }
        $exePath = Join-Path $InstallDir $exeName
        Start-Process -FilePath $exePath -WorkingDirectory $InstallDir
        Write-Log "Softphone started"
    } else {
        Write-Log "DEPLOYMENT COMPLETED WITH WARNINGS -- review errors above" "WARN"
    }

} catch {
    Write-Log "DEPLOYMENT FAILED: $_" "ERROR"

    # Rollback if we have a backup
    if ($backupDir -and (Test-Path $backupDir)) {
        Write-Log "Rolling back to backup: $backupDir" "WARN"
        Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
        Rename-Item -Path $backupDir -NewName "ViciPhone" -ErrorAction SilentlyContinue
        Write-Log "Rollback complete"
    }

    exit 1
}

Deployment Package Directory Structure

Organize your file server share like this:

\\DEPLOY_SERVER\viciphone\
    bin\
        tSIP.exe
        microsip.exe
    configs\
        tsip\
            tSIP_1031.json
            tSIP_1032.json
            ...
            tSIP_1070.json
        microsip\
            microsip_1031.ini
            microsip_1032.ini
            ...
            microsip_1070.ini
    scripts\
        deploy-viciphone.ps1
        uninstall-viciphone.ps1
        watchdog.ps1
        generate_configs.py
        extensions.csv

Part 6: Watchdog Script

The watchdog is the silent hero of this system. It runs as a scheduled task every 2 minutes, checks if the softphone process is alive, and restarts it if it has crashed. It also logs restarts so you can spot machines with recurring crashes.

Save as watchdog.ps1:

<#
.SYNOPSIS
    ViciPhone Watchdog - Restarts the softphone if it is not running.

.DESCRIPTION
    Designed to run as a scheduled task every 2 minutes.
    - Checks if the softphone process is running
    - If not, starts it
    - Logs all restart events with timestamps
    - Optionally notifies a central monitoring server
    - Limits restart attempts to avoid restart loops on persistent crashes
#>

# ============================================================
# CONFIGURATION
# ============================================================
$InstallDir       = "C:\ViciPhone"
$LogFile          = "C:\ViciPhone\watchdog\watchdog.log"
$MaxLogSizeKB     = 512
$MaxRestartsPerHour = 10
$RestartCountFile = "C:\ViciPhone\watchdog\restart_count.txt"

# Event receiver URL (set to empty string to disable)
$EventUrl         = "http://MONITOR_SERVER:8090/event/watchdog"

# Detect which softphone is installed
if (Test-Path (Join-Path $InstallDir "tSIP.exe")) {
    $ProcessName = "tSIP"
    $ExePath     = Join-Path $InstallDir "tSIP.exe"
} elseif (Test-Path (Join-Path $InstallDir "microsip.exe")) {
    $ProcessName = "microsip"
    $ExePath     = Join-Path $InstallDir "microsip.exe"
} else {
    # No softphone installed, exit silently
    exit 0
}

# ============================================================
# FUNCTIONS
# ============================================================

function Write-WatchdogLog {
    param([string]$Message)
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logLine = "[$timestamp] $Message"

    # Ensure log directory exists
    $logDir = Split-Path $LogFile -Parent
    if (-not (Test-Path $logDir)) {
        New-Item -Path $logDir -ItemType Directory -Force | Out-Null
    }

    # Rotate log if too large
    if (Test-Path $LogFile) {
        $logSize = (Get-Item $LogFile).Length / 1KB
        if ($logSize -gt $MaxLogSizeKB) {
            $archivePath = "$LogFile.old"
            Move-Item -Path $LogFile -Destination $archivePath -Force
        }
    }

    Add-Content -Path $LogFile -Value $logLine
}

function Get-RestartCount {
    <#
    Tracks restart count per hour to prevent infinite restart loops.
    If the softphone crashes immediately on start, we do not want the
    watchdog hammering it 30 times per hour.
    #>
    if (-not (Test-Path $RestartCountFile)) {
        return @{ Hour = (Get-Date).Hour; Count = 0 }
    }

    $content = Get-Content $RestartCountFile -Raw | ConvertFrom-Json
    $currentHour = (Get-Date).Hour

    if ($content.Hour -ne $currentHour) {
        # New hour, reset counter
        return @{ Hour = $currentHour; Count = 0 }
    }

    return @{ Hour = $content.Hour; Count = $content.Count }
}

function Save-RestartCount {
    param([hashtable]$CountData)
    $CountData | ConvertTo-Json | Set-Content -Path $RestartCountFile -Force
}

function Send-WatchdogEvent {
    param([string]$EventType, [string]$Detail)

    if ([string]::IsNullOrEmpty($EventUrl)) { return }

    $hostname = $env:COMPUTERNAME
    $body = @{
        hostname   = $hostname
        process    = $ProcessName
        event      = $EventType
        detail     = $Detail
        timestamp  = (Get-Date -Format "o")
    } | ConvertTo-Json

    try {
        Invoke-RestMethod -Uri $EventUrl -Method Post -Body $body `
            -ContentType "application/json" -TimeoutSec 5 -ErrorAction Stop
    } catch {
        Write-WatchdogLog "Failed to send event to $EventUrl : $_"
    }
}

# ============================================================
# MAIN LOGIC
# ============================================================

$process = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue

if ($process) {
    # Softphone is running. Nothing to do.
    # Uncomment the next line for verbose logging (generates a lot of output):
    # Write-WatchdogLog "OK: $ProcessName is running (PID: $($process.Id))"
    exit 0
}

# Softphone is NOT running. Attempt restart.
Write-WatchdogLog "ALERT: $ProcessName is not running. Attempting restart..."

# Check restart rate limit
$restartData = Get-RestartCount
if ($restartData.Count -ge $MaxRestartsPerHour) {
    $msg = "CRITICAL: Restart limit reached ($MaxRestartsPerHour/hour). Giving up. Manual intervention required."
    Write-WatchdogLog $msg
    Send-WatchdogEvent "restart_limit_reached" $msg
    exit 1
}

# Attempt restart
try {
    Start-Process -FilePath $ExePath -WorkingDirectory $InstallDir -ErrorAction Stop
    Start-Sleep -Seconds 3

    # Verify it actually started
    $check = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue
    if ($check) {
        $msg = "Restarted $ProcessName successfully (new PID: $($check.Id))"
        Write-WatchdogLog $msg
        Send-WatchdogEvent "restarted" $msg
    } else {
        $msg = "Started $ProcessName but process not found after 3 seconds"
        Write-WatchdogLog $msg
        Send-WatchdogEvent "restart_failed" $msg
    }

    # Update restart counter
    $restartData.Count++
    Save-RestartCount $restartData

} catch {
    $msg = "Failed to start $ProcessName : $_"
    Write-WatchdogLog $msg
    Send-WatchdogEvent "restart_error" $msg
    exit 1
}

How the Watchdog Prevents Restart Storms

If tSIP has a corrupted config or a DLL dependency issue, it will crash immediately on startup. Without rate limiting, the watchdog would restart it every 2 minutes, creating 30 crash-restart cycles per hour. Each cycle writes logs, fires events, and wastes resources.

The watchdog tracks restarts per hour in a small JSON file. After 10 restarts in the same clock hour, it stops trying and sends a restart_limit_reached event to the monitoring server. This is your signal to investigate that specific machine.


Part 7: Uninstaller

Clean removal is just as important as clean deployment. When an agent leaves or moves to a different extension, you need to remove everything without leaving orphaned scheduled tasks, registry entries, or stale shortcuts.

Save as uninstall-viciphone.ps1:

<#
.SYNOPSIS
    ViciPhone Uninstaller - Completely removes softphone deployment.

.DESCRIPTION
    Removes all traces of a ViciPhone deployment:
    - Stops softphone process
    - Removes scheduled task (watchdog)
    - Removes auto-start registry entry
    - Removes desktop shortcut
    - Removes install directory (with optional backup)

.PARAMETER KeepBackup
    If set, backs up the install directory before deletion.

.PARAMETER Unattended
    Skip confirmation prompts. Use for mass uninstall.

.EXAMPLE
    .\uninstall-viciphone.ps1
    .\uninstall-viciphone.ps1 -KeepBackup -Unattended
#>

param(
    [switch]$KeepBackup,
    [switch]$Unattended
)

$InstallDir     = "C:\ViciPhone"
$ShortcutName   = "ViciPhone"
$TaskName       = "ViciPhone-Watchdog"
$RegistryRunKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"

function Write-Status {
    param([string]$Message, [string]$Status = "OK")
    $color = switch ($Status) {
        "OK"    { "Green" }
        "WARN"  { "Yellow" }
        "ERROR" { "Red" }
        "SKIP"  { "Gray" }
        default { "White" }
    }
    Write-Host "[$Status] $Message" -ForegroundColor $color
}

if (-not $Unattended) {
    Write-Host ""
    Write-Host "ViciPhone Uninstaller" -ForegroundColor Cyan
    Write-Host "=====================" -ForegroundColor Cyan
    Write-Host "This will completely remove ViciPhone from this machine."
    Write-Host ""
    $confirm = Read-Host "Continue? [y/N]"
    if ($confirm -ne "y") {
        Write-Host "Cancelled."
        exit 0
    }
}

# 1. Stop softphone processes
foreach ($proc in @("tSIP", "microsip")) {
    $running = Get-Process -Name $proc -ErrorAction SilentlyContinue
    if ($running) {
        Stop-Process -Name $proc -Force
        Start-Sleep -Seconds 1
        Write-Status "Stopped $proc process (PID: $($running.Id))"
    }
}

# 2. Remove watchdog scheduled task
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
    Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
    Write-Status "Removed scheduled task: $TaskName"
} else {
    Write-Status "No scheduled task found: $TaskName" "SKIP"
}

# 3. Remove auto-start registry entry
$regEntry = Get-ItemProperty -Path $RegistryRunKey -Name $ShortcutName -ErrorAction SilentlyContinue
if ($regEntry) {
    Remove-ItemProperty -Path $RegistryRunKey -Name $ShortcutName -Force
    Write-Status "Removed auto-start registry entry"
} else {
    Write-Status "No auto-start registry entry found" "SKIP"
}

# 4. Remove desktop shortcut
$desktopPath = [Environment]::GetFolderPath("Desktop")
$shortcutPath = Join-Path $desktopPath "$ShortcutName.lnk"
if (Test-Path $shortcutPath) {
    Remove-Item $shortcutPath -Force
    Write-Status "Removed desktop shortcut"
} else {
    Write-Status "No desktop shortcut found" "SKIP"
}

# 5. Remove MicroSIP registry keys (if present)
$microsipRegPath = "HKCU:\Software\MicroSIP"
if (Test-Path $microsipRegPath) {
    Remove-Item $microsipRegPath -Recurse -Force
    Write-Status "Removed MicroSIP registry keys"
}

# 6. Backup and/or remove install directory
if (Test-Path $InstallDir) {
    if ($KeepBackup) {
        $backupDir = "${InstallDir}_uninstall_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
        Copy-Item -Path $InstallDir -Destination $backupDir -Recurse -Force
        Write-Status "Backed up to $backupDir"
    }

    Remove-Item -Path $InstallDir -Recurse -Force
    Write-Status "Removed install directory: $InstallDir"
} else {
    Write-Status "Install directory not found: $InstallDir" "SKIP"
}

Write-Host ""
Write-Host "ViciPhone has been completely removed." -ForegroundColor Cyan

Part 8: Remote Deployment via WinRM

This is where it all comes together. Instead of logging into 40 machines one by one, you deploy to all of them from a single management workstation using PowerShell Remoting (WinRM).

Enabling WinRM on Agent Machines

Before you can deploy remotely, each agent machine needs WinRM enabled. Run this once per machine (or push it via Group Policy):

# Run on each agent machine (elevated prompt)
Enable-PSRemoting -Force -SkipNetworkProfileCheck

# If machines are not domain-joined, allow connections by IP
Set-Item WSMan:\localhost\Client\TrustedHosts -Value "*" -Force

# Verify
Test-WSMan -ComputerName localhost

For domain-joined machines, WinRM is typically enabled by default. For workgroup machines (like Kamatera VMs), you need the TrustedHosts entry on both the management machine and the target.

Mass Deployment Script

Save as deploy-remote.ps1:

<#
.SYNOPSIS
    Remote mass deployment of ViciPhone to multiple agent machines.

.DESCRIPTION
    Reads extensions.csv, connects to each machine via WinRM, and runs
    the deployment script remotely. Supports parallel deployment.

.PARAMETER CsvPath
    Path to extensions CSV (must include machine_ip column).

.PARAMETER Credential
    PSCredential object for WinRM authentication. If not provided, prompts.

.PARAMETER ThrottleLimit
    Maximum number of parallel deployments. Default: 5.

.PARAMETER SoftphoneType
    Which softphone to deploy: tsip or microsip. Default: tsip.

.EXAMPLE
    .\deploy-remote.ps1 -CsvPath .\extensions.csv -ThrottleLimit 10
#>

param(
    [string]$CsvPath = ".\extensions.csv",
    [PSCredential]$Credential,
    [int]$ThrottleLimit = 5,
    [ValidateSet("tsip", "microsip")]
    [string]$SoftphoneType = "tsip",
    [string]$DeploySharePath = "\\DEPLOY_SERVER\viciphone"
)

# Prompt for credentials if not provided
if (-not $Credential) {
    $Credential = Get-Credential -Message "Enter admin credentials for agent machines"
}

# Read the CSV
$extensions = Import-Csv $CsvPath
$totalCount = $extensions.Count
$results = @()

Write-Host ""
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host "ViciPhone Remote Mass Deployment" -ForegroundColor Cyan
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host "Machines: $totalCount"
Write-Host "Softphone: $SoftphoneType"
Write-Host "Throttle: $ThrottleLimit parallel"
Write-Host "Deploy share: $DeploySharePath"
Write-Host ""

$confirm = Read-Host "Proceed with deployment to $totalCount machines? [y/N]"
if ($confirm -ne "y") {
    Write-Host "Cancelled."
    exit 0
}

# Deploy to each machine
$counter = 0
foreach ($ext in $extensions) {
    $counter++
    $ip = $ext.machine_ip
    $extNum = $ext.extension
    $agentName = $ext.agent_name

    Write-Host ""
    Write-Host "[$counter/$totalCount] Deploying ext $extNum to $ip ($agentName)..." -ForegroundColor Yellow

    $result = [PSCustomObject]@{
        Extension = $extNum
        MachineIP = $ip
        AgentName = $agentName
        Status    = "Unknown"
        Error     = ""
        Duration  = 0
    }

    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        # Test connectivity first
        $reachable = Test-Connection -ComputerName $ip -Count 1 -Quiet -TimeoutSeconds 5
        if (-not $reachable) {
            throw "Machine unreachable (ping failed)"
        }

        # Test WinRM
        $wsmanResult = Test-WSMan -ComputerName $ip -ErrorAction Stop

        # Create remote session
        $sessionOptions = New-PSSessionOption -OpenTimeout 15000 -OperationTimeout 60000
        $session = New-PSSession -ComputerName $ip -Credential $Credential `
            -SessionOption $sessionOptions -ErrorAction Stop

        # Copy deployment script to remote machine
        $remoteScriptPath = "C:\Windows\Temp\deploy-viciphone.ps1"
        Copy-Item -Path ".\deploy-viciphone.ps1" -Destination $remoteScriptPath `
            -ToSession $session -Force

        # Run deployment remotely
        $deployResult = Invoke-Command -Session $session -ScriptBlock {
            param($ScriptPath, $Extension, $SoftphoneType, $SharePath)

            & $ScriptPath `
                -Extension $Extension `
                -SoftphoneType $SoftphoneType `
                -SourcePath $SharePath `
                -Unattended

        } -ArgumentList $remoteScriptPath, $extNum, $SoftphoneType, $DeploySharePath `
          -ErrorAction Stop

        $result.Status = "SUCCESS"
        Write-Host "  -> SUCCESS" -ForegroundColor Green

        # Clean up remote session
        Remove-PSSession $session

    } catch {
        $result.Status = "FAILED"
        $result.Error = $_.Exception.Message
        Write-Host "  -> FAILED: $($_.Exception.Message)" -ForegroundColor Red

        # Clean up session if it exists
        if ($session) {
            Remove-PSSession $session -ErrorAction SilentlyContinue
        }
    }

    $stopwatch.Stop()
    $result.Duration = $stopwatch.Elapsed.TotalSeconds
    $results += $result
}

# ============================================================
# SUMMARY REPORT
# ============================================================
Write-Host ""
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host "DEPLOYMENT SUMMARY" -ForegroundColor Cyan
Write-Host "=======================================" -ForegroundColor Cyan

$succeeded = ($results | Where-Object { $_.Status -eq "SUCCESS" }).Count
$failed = ($results | Where-Object { $_.Status -eq "FAILED" }).Count

Write-Host "Total:     $totalCount" -ForegroundColor White
Write-Host "Succeeded: $succeeded" -ForegroundColor Green
Write-Host "Failed:    $failed" -ForegroundColor $(if ($failed -gt 0) { "Red" } else { "Green" })

if ($failed -gt 0) {
    Write-Host ""
    Write-Host "FAILED MACHINES:" -ForegroundColor Red
    $results | Where-Object { $_.Status -eq "FAILED" } | ForEach-Object {
        Write-Host "  $($_.MachineIP) (ext $($_.Extension)): $($_.Error)" -ForegroundColor Red
    }
}

# Export results to CSV
$reportPath = ".\deployment_report_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$results | Export-Csv -Path $reportPath -NoTypeInformation
Write-Host ""
Write-Host "Full report saved to: $reportPath"

Deploying to a Single Machine

For quick one-off deployments (new hire, replacement machine):

# Deploy to a single machine
$cred = Get-Credential
Invoke-Command -ComputerName 192.168.1.101 -Credential $cred -ScriptBlock {
    & "\\DEPLOY_SERVER\viciphone\scripts\deploy-viciphone.ps1" `
        -Extension 1031 -SoftphoneType tsip -Unattended
}

Remote Uninstall

# Uninstall from a single machine
Invoke-Command -ComputerName 192.168.1.101 -Credential $cred -ScriptBlock {
    & "\\DEPLOY_SERVER\viciphone\scripts\uninstall-viciphone.ps1" -Unattended
}

# Uninstall from all machines in the CSV
Import-Csv .\extensions.csv | ForEach-Object {
    Write-Host "Uninstalling from $($_.machine_ip)..."
    Invoke-Command -ComputerName $_.machine_ip -Credential $cred -ScriptBlock {
        & "\\DEPLOY_SERVER\viciphone\scripts\uninstall-viciphone.ps1" -Unattended -KeepBackup
    }
}

Part 9: FastAPI Event Receiver

The event receiver is a lightweight Python service that listens for softphone events (registration changes, watchdog restarts, call state changes) and stores them for monitoring. This gives you a central dashboard of which softphones are healthy and which need attention.

Save as viciphone_events.py:

#!/usr/bin/env python3
"""
ViciPhone Event Receiver
FastAPI service that collects softphone events from deployed agents.
Runs on port 8090.

Events received:
    - /event/registration  -- SIP registration state changes
    - /event/call          -- Call start/end events
    - /event/watchdog      -- Watchdog restart events

Usage:
    pip install fastapi uvicorn
    python3 viciphone_events.py
    # Or with uvicorn directly:
    uvicorn viciphone_events:app --host 0.0.0.0 --port 8090
"""

import json
import sqlite3
import logging
from datetime import datetime, timedelta
from pathlib import Path
from contextlib import contextmanager

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse

# ============================================================
# CONFIGURATION
# ============================================================
DB_PATH = "/opt/viciphone-events/events.db"
LOG_PATH = "/opt/viciphone-events/events.log"
MAX_EVENTS_AGE_DAYS = 30

# ============================================================
# SETUP
# ============================================================
logging.basicConfig(
    filename=LOG_PATH,
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("viciphone-events")

app = FastAPI(title="ViciPhone Event Receiver", version="1.0")


def init_db():
    """Create the events database and tables if they don't exist."""
    Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                hostname TEXT,
                event_type TEXT NOT NULL,
                process TEXT,
                detail TEXT,
                source_ip TEXT,
                raw_data TEXT
            )
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_events_timestamp
            ON events(timestamp DESC)
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_events_hostname
            ON events(hostname, timestamp DESC)
        """)
    logger.info("Database initialized: %s", DB_PATH)


@contextmanager
def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    finally:
        conn.close()


# ============================================================
# EVENT ENDPOINTS
# ============================================================

@app.post("/event/registration")
async def event_registration(request: Request):
    """Receive SIP registration state change events."""
    body = await request.json()
    source_ip = request.client.host

    with get_db() as db:
        db.execute(
            "INSERT INTO events (timestamp, hostname, event_type, process, detail, source_ip, raw_data) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                body.get("timestamp", datetime.utcnow().isoformat()),
                body.get("hostname", "unknown"),
                "registration",
                body.get("process", ""),
                body.get("detail", body.get("state", "")),
                source_ip,
                json.dumps(body),
            ),
        )

    logger.info(
        "Registration event from %s (%s): %s",
        body.get("hostname"),
        source_ip,
        body.get("detail", body.get("state", "")),
    )
    return {"status": "ok"}


@app.post("/event/call")
async def event_call(request: Request):
    """Receive call state change events."""
    body = await request.json()
    source_ip = request.client.host

    with get_db() as db:
        db.execute(
            "INSERT INTO events (timestamp, hostname, event_type, process, detail, source_ip, raw_data) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                body.get("timestamp", datetime.utcnow().isoformat()),
                body.get("hostname", "unknown"),
                "call",
                body.get("process", ""),
                body.get("detail", body.get("state", "")),
                source_ip,
                json.dumps(body),
            ),
        )

    logger.info(
        "Call event from %s (%s): %s",
        body.get("hostname"),
        source_ip,
        body.get("detail", body.get("state", "")),
    )
    return {"status": "ok"}


@app.post("/event/watchdog")
async def event_watchdog(request: Request):
    """Receive watchdog events (restart, failure, etc.)."""
    body = await request.json()
    source_ip = request.client.host

    with get_db() as db:
        db.execute(
            "INSERT INTO events (timestamp, hostname, event_type, process, detail, source_ip, raw_data) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                body.get("timestamp", datetime.utcnow().isoformat()),
                body.get("hostname", "unknown"),
                "watchdog",
                body.get("process", ""),
                body.get("detail", body.get("event", "")),
                source_ip,
                json.dumps(body),
            ),
        )

    level = logging.WARNING if "fail" in body.get("event", "").lower() else logging.INFO
    logger.log(
        level,
        "Watchdog event from %s (%s): %s - %s",
        body.get("hostname"),
        source_ip,
        body.get("event"),
        body.get("detail"),
    )
    return {"status": "ok"}


# ============================================================
# MONITORING / STATUS ENDPOINTS
# ============================================================

@app.get("/status")
async def status():
    """Return overall system status."""
    with get_db() as db:
        # Total events in last 24 hours
        cutoff = (datetime.utcnow() - timedelta(hours=24)).isoformat()
        row = db.execute(
            "SELECT COUNT(*) as cnt FROM events WHERE timestamp > ?", (cutoff,)
        ).fetchone()
        events_24h = row["cnt"]

        # Unique hosts seen in last 24 hours
        row = db.execute(
            "SELECT COUNT(DISTINCT hostname) as cnt FROM events WHERE timestamp > ?",
            (cutoff,),
        ).fetchone()
        hosts_24h = row["cnt"]

        # Watchdog restarts in last 24 hours
        row = db.execute(
            "SELECT COUNT(*) as cnt FROM events "
            "WHERE event_type = 'watchdog' AND detail LIKE '%restart%' AND timestamp > ?",
            (cutoff,),
        ).fetchone()
        restarts_24h = row["cnt"]

    return {
        "service": "ViciPhone Event Receiver",
        "status": "healthy",
        "events_last_24h": events_24h,
        "hosts_last_24h": hosts_24h,
        "watchdog_restarts_24h": restarts_24h,
    }


@app.get("/events/recent")
async def recent_events(limit: int = 50, event_type: str = None):
    """Return recent events, optionally filtered by type."""
    with get_db() as db:
        if event_type:
            rows = db.execute(
                "SELECT * FROM events WHERE event_type = ? ORDER BY timestamp DESC LIMIT ?",
                (event_type, limit),
            ).fetchall()
        else:
            rows = db.execute(
                "SELECT * FROM events ORDER BY timestamp DESC LIMIT ?", (limit,)
            ).fetchall()

    return [dict(row) for row in rows]


@app.get("/events/host/{hostname}")
async def host_events(hostname: str, limit: int = 50):
    """Return recent events for a specific host."""
    with get_db() as db:
        rows = db.execute(
            "SELECT * FROM events WHERE hostname = ? ORDER BY timestamp DESC LIMIT ?",
            (hostname, limit),
        ).fetchall()

    return [dict(row) for row in rows]


@app.get("/hosts")
async def list_hosts():
    """Return all known hosts with their last event."""
    with get_db() as db:
        rows = db.execute("""
            SELECT hostname, event_type, detail, timestamp, source_ip
            FROM events
            WHERE id IN (
                SELECT MAX(id) FROM events GROUP BY hostname
            )
            ORDER BY hostname
        """).fetchall()

    return [dict(row) for row in rows]


@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard():
    """Simple HTML dashboard showing softphone fleet status."""
    with get_db() as db:
        cutoff = (datetime.utcnow() - timedelta(hours=1)).isoformat()

        # Get latest status per host
        hosts = db.execute("""
            SELECT hostname, event_type, detail, timestamp, source_ip
            FROM events
            WHERE id IN (
                SELECT MAX(id) FROM events GROUP BY hostname
            )
            ORDER BY hostname
        """).fetchall()

        # Get watchdog alerts
        alerts = db.execute(
            "SELECT * FROM events WHERE event_type = 'watchdog' "
            "AND timestamp > ? ORDER BY timestamp DESC LIMIT 20",
            (cutoff,),
        ).fetchall()

    host_rows = ""
    for h in hosts:
        status_color = "green" if "registered" in (h["detail"] or "").lower() else "orange"
        if "fail" in (h["detail"] or "").lower() or "error" in (h["detail"] or "").lower():
            status_color = "red"

        host_rows += f"""
        <tr>
            <td>{h['hostname']}</td>
            <td>{h['source_ip']}</td>
            <td>{h['event_type']}</td>
            <td style="color: {status_color}; font-weight: bold;">{h['detail']}</td>
            <td>{h['timestamp']}</td>
        </tr>"""

    alert_rows = ""
    for a in alerts:
        alert_rows += f"""
        <tr>
            <td>{a['hostname']}</td>
            <td>{a['detail']}</td>
            <td>{a['timestamp']}</td>
        </tr>"""

    return f"""
    <!DOCTYPE html>
    <html>
    <head>
        <title>ViciPhone Fleet Status</title>
        <meta http-equiv="refresh" content="30">
        <style>
            body {{ font-family: -apple-system, sans-serif; margin: 20px; background: #1a1a2e; color: #eee; }}
            h1, h2 {{ color: #e94560; }}
            table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
            th, td {{ border: 1px solid #333; padding: 8px 12px; text-align: left; }}
            th {{ background: #16213e; color: #e94560; }}
            tr:nth-child(even) {{ background: #16213e; }}
            tr:hover {{ background: #0f3460; }}
        </style>
    </head>
    <body>
        <h1>ViciPhone Fleet Status</h1>
        <p>Auto-refreshes every 30 seconds.</p>

        <h2>Hosts ({len(hosts)})</h2>
        <table>
            <tr><th>Hostname</th><th>IP</th><th>Last Event</th><th>Status</th><th>Timestamp</th></tr>
            {host_rows}
        </table>

        <h2>Recent Watchdog Alerts</h2>
        <table>
            <tr><th>Hostname</th><th>Detail</th><th>Timestamp</th></tr>
            {alert_rows if alert_rows else '<tr><td colspan="3">No alerts in the last hour</td></tr>'}
        </table>
    </body>
    </html>
    """


# ============================================================
# MAINTENANCE
# ============================================================

@app.on_event("startup")
async def startup():
    init_db()
    logger.info("ViciPhone Event Receiver started on port 8090")


@app.post("/maintenance/purge")
async def purge_old_events(days: int = 30):
    """Remove events older than N days."""
    cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
    with get_db() as db:
        result = db.execute("DELETE FROM events WHERE timestamp < ?", (cutoff,))
        deleted = result.rowcount
    logger.info("Purged %d events older than %d days", deleted, days)
    return {"deleted": deleted, "cutoff": cutoff}


# ============================================================
# ENTRYPOINT
# ============================================================
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8090)

Running the Event Receiver as a Systemd Service

# /etc/systemd/system/viciphone-events.service
[Unit]
Description=ViciPhone Event Receiver
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/viciphone-events
ExecStart=/usr/bin/python3 /opt/viciphone-events/viciphone_events.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
# Install and start
sudo mkdir -p /opt/viciphone-events
sudo cp viciphone_events.py /opt/viciphone-events/
sudo pip3 install fastapi uvicorn
sudo systemctl daemon-reload
sudo systemctl enable --now viciphone-events

# Verify
curl http://localhost:8090/status

Part 10: Testing Before Mass Rollout

Never deploy to 40 machines at once on the first attempt. Follow this testing ladder:

Step 1: Local Test (Your Own Machine)

# Run the deployment script locally
.\deploy-viciphone.ps1 -Extension 9999 -SoftphoneType tsip -SourcePath "C:\deploy-package"

Verify:

Step 2: Single Remote Test

Pick one agent machine (preferably one you can walk up to or RDP into):

$cred = Get-Credential
Invoke-Command -ComputerName 192.168.1.101 -Credential $cred -ScriptBlock {
    & "\\DEPLOY_SERVER\viciphone\scripts\deploy-viciphone.ps1" `
        -Extension 1031 -SoftphoneType tsip -Unattended
}

Verify everything from Step 1, but also:

Step 3: Small Batch (5 Machines)

Deploy to 5 machines. Monitor the event receiver dashboard for 30 minutes. Look for:

Step 4: Full Deployment

Once the batch of 5 is stable, run the mass deployment:

.\deploy-remote.ps1 -CsvPath .\extensions.csv -ThrottleLimit 5

Review the deployment report CSV. Address any failures individually.

Common Test Call Procedure

1. Log an agent into ViciDial on the target machine
2. Place an inbound test call to a DID routed to that agent's ingroup
3. Verify:
   a. tSIP auto-answers (no ring, no popup -- just connects)
   b. Two-way audio works (talk and listen)
   c. No echo or robotic artifacts
   d. The ViciDial agent interface shows the call correctly
4. End the call from the caller side
5. Verify tSIP returns to idle state

Part 11: Handling Updates

Softphone updates fall into three categories:

Configuration Change (Codec, SIP Server, etc.)

  1. Regenerate configs: python3 generate_configs.py --format tsip --output ./configs/tsip/
  2. Push new config to each machine:
# Update config for a single extension
$session = New-PSSession -ComputerName 192.168.1.101 -Credential $cred

Invoke-Command -Session $session -ScriptBlock {
    Stop-Process -Name "tSIP" -Force -ErrorAction SilentlyContinue
    Start-Sleep -Seconds 2
}

# Remove read-only flag, copy new config, re-apply read-only
Invoke-Command -Session $session -ScriptBlock {
    $configPath = "C:\ViciPhone\tSIP.json"
    Set-ItemProperty -Path $configPath -Name IsReadOnly -Value $false
}

Copy-Item -Path ".\configs\tsip\tSIP_1031.json" -Destination "C:\ViciPhone\tSIP.json" `
    -ToSession $session -Force

Invoke-Command -Session $session -ScriptBlock {
    $configPath = "C:\ViciPhone\tSIP.json"
    Set-ItemProperty -Path $configPath -Name IsReadOnly -Value $true
    Start-Process -FilePath "C:\ViciPhone\tSIP.exe" -WorkingDirectory "C:\ViciPhone"
}

Remove-PSSession $session

Softphone Binary Update

  1. Download new tSIP/MicroSIP binary
  2. Place it in the \\DEPLOY_SERVER\viciphone\bin\ share
  3. Re-run the deployment script -- it backs up the old install and deploys fresh:
# Mass update binary on all machines
Import-Csv .\extensions.csv | ForEach-Object {
    $ip = $_.machine_ip
    $ext = $_.extension
    Write-Host "Updating $ip (ext $ext)..."

    Invoke-Command -ComputerName $ip -Credential $cred -ScriptBlock {
        param($ext)
        & "\\DEPLOY_SERVER\viciphone\scripts\deploy-viciphone.ps1" `
            -Extension $ext -SoftphoneType tsip -Unattended
    } -ArgumentList $ext
}

Extension Reassignment

When an agent moves to a different extension:

# 1. Uninstall old
Invoke-Command -ComputerName 192.168.1.101 -Credential $cred -ScriptBlock {
    & "\\DEPLOY_SERVER\viciphone\scripts\uninstall-viciphone.ps1" -Unattended
}

# 2. Deploy new extension
Invoke-Command -ComputerName 192.168.1.101 -Credential $cred -ScriptBlock {
    & "\\DEPLOY_SERVER\viciphone\scripts\deploy-viciphone.ps1" `
        -Extension 1055 -SoftphoneType tsip -Unattended
}

Part 12: Monitoring Connection Quality

Deploying the softphone is only half the battle. You need ongoing visibility into whether phones are healthy.

Quick Health Check Script

Run this from your management machine to check registration status across all agents:

# check-registration.ps1
# Quick check which softphones are registered

$results = Import-Csv .\extensions.csv | ForEach-Object {
    $ip = $_.machine_ip
    $ext = $_.extension

    try {
        $status = Invoke-Command -ComputerName $ip -Credential $cred -ScriptBlock {
            $proc = Get-Process -Name "tSIP" -ErrorAction SilentlyContinue
            if ($proc) {
                # Read the most recent log line for registration status
                $logFile = "C:\ViciPhone\tSIP_log.txt"
                if (Test-Path $logFile) {
                    $lastReg = Select-String -Path $logFile -Pattern "registration" |
                        Select-Object -Last 1
                    return @{
                        Running = $true
                        PID = $proc.Id
                        Uptime = ((Get-Date) - $proc.StartTime).TotalHours
                        LastRegLine = $lastReg.Line
                    }
                }
                return @{ Running = $true; PID = $proc.Id; Uptime = 0; LastRegLine = "no log" }
            }
            return @{ Running = $false; PID = 0; Uptime = 0; LastRegLine = "not running" }
        } -ErrorAction Stop

        [PSCustomObject]@{
            Extension = $ext
            IP        = $ip
            Running   = $status.Running
            PID       = $status.PID
            UptimeHrs = [math]::Round($status.Uptime, 1)
            Status    = $status.LastRegLine
        }
    } catch {
        [PSCustomObject]@{
            Extension = $ext
            IP        = $ip
            Running   = "ERROR"
            PID       = 0
            UptimeHrs = 0
            Status    = $_.Exception.Message
        }
    }
}

$results | Format-Table -AutoSize

Integrating with Grafana

If you run Grafana (and you should), point a JSON datasource at the event receiver:

  1. Add a JSON datasource pointing to http://MONITOR_SERVER:8090
  2. Create a dashboard with panels:
    • Registered Hosts -- query /hosts, count where detail contains "registered"
    • Watchdog Restarts (24h) -- query /status, display watchdog_restarts_24h
    • Recent Alerts -- query /events/recent?event_type=watchdog&limit=20

Network Quality Indicators

Watch for these patterns in your monitoring:

Symptom Likely Cause Fix
Frequent re-registrations Unstable network, NAT timeout Reduce reg_expires to 60s, check firewall
One-way audio NAT/firewall blocking RTP Ensure RTP port range (10000-10100) is open
Choppy audio Packet loss, jitter Check network path, enable jitter buffer
Watchdog restarts every few minutes Crash bug, missing DLL Check tSIP logs, reinstall
Registration timeout Wrong SIP server/port, firewall Verify SIP credentials, check connectivity

Troubleshooting

WinRM Connection Issues

"Access denied" errors:

# On the agent machine, verify WinRM is listening
winrm enumerate winrm/config/listener

# If the management machine is not domain-joined, add it to TrustedHosts
Set-Item WSMan:\localhost\Client\TrustedHosts -Value "MANAGEMENT_IP" -Force
Restart-Service WinRM

"The WinRM client cannot process the request":

# Enable basic auth if using local accounts (not domain)
winrm set winrm/config/service/auth @{Basic="true"}
winrm set winrm/config/service @{AllowUnencrypted="true"}

Note: In production, use HTTPS (port 5986) with proper certificates instead of unencrypted HTTP.

Softphone Won't Register

  1. Check credentials: Verify extension/password match what Asterisk expects
  2. Check connectivity: Test-NetConnection -ComputerName SIP_SERVER -Port 5060
  3. Check firewall: Ensure UDP 5060 (SIP) and UDP 10000-20000 (RTP) are open
  4. Check Asterisk: asterisk -rx "sip show peers" -- is the extension showing up?

Audio Issues After Deployment

  1. Codec mismatch: Verify PRIMARY_CODEC matches your trunk. Run asterisk -rx "core show codecs" and asterisk -rx "sip show peer EXTENSION" to confirm.
  2. RTP port conflict: If multiple softphones run on the same machine (unlikely but possible in testing), they'll fight over the same RTP ports. Use different rtp_port_min/rtp_port_max ranges.
  3. Audio device selection: If the machine has multiple audio devices (common with VMs), MicroSIP may pick the wrong one. Set audioInputDevice and audioOutputDevice explicitly in the config.

Watchdog Running But Not Restarting

  1. Check the watchdog log: type C:\ViciPhone\watchdog\watchdog.log
  2. If you see "Restart limit reached," the softphone is crash-looping. Check the tSIP log for the root cause.
  3. Verify the scheduled task is running: Get-ScheduledTask -TaskName "ViciPhone-Watchdog" | Select-Object State

Summary

This tutorial covered the complete lifecycle of softphone management at scale:

  1. Configuration design -- locked-down tSIP and MicroSIP templates with correct codec priority, auto-answer, SRTP, and UI lockdown.
  2. Bulk generation -- Python script that reads a CSV and produces one config file per extension, for either softphone type.
  3. Automated deployment -- PowerShell script that installs the binary, applies the config, sets auto-start, creates a desktop shortcut, and installs the watchdog. Includes backup and rollback.
  4. Watchdog -- Scheduled task that checks every 2 minutes and restarts the softphone if it has crashed, with rate limiting to prevent restart storms.
  5. Clean uninstallation -- Removes everything: process, scheduled task, registry entry, shortcut, and install directory.
  6. Remote mass deployment -- WinRM-based script that deploys to all machines in a CSV, with connectivity pre-checks, error handling, and a summary report.
  7. Central monitoring -- FastAPI event receiver that collects registration, call, and watchdog events, with a built-in HTML dashboard and JSON API endpoints for Grafana integration.

The entire system uses free software (tSIP, MicroSIP, PowerShell, Python) and can be set up in an afternoon. Once it's running, new agent onboarding takes 30 seconds instead of 15 minutes, and you'll know within 2 minutes if any softphone goes down.

File Inventory

File Purpose
generate_configs.py Bulk config generation from CSV
extensions.csv Extension/password/machine mapping
tSIP_template.json tSIP locked-down config template
microsip_template.ini MicroSIP config template
deploy-viciphone.ps1 Local deployment script
deploy-remote.ps1 Remote mass deployment via WinRM
watchdog.ps1 Softphone process monitor
uninstall-viciphone.ps1 Clean removal script
viciphone_events.py Central event receiver (FastAPI)
viciphone-events.service Systemd unit for event receiver
check-registration.ps1 Quick fleet health check

This tutorial is part of a series on VoIP infrastructure automation for call centers. It assumes familiarity with SIP, Asterisk, and Windows administration.

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation