Tutorial 16: Automated Softphone Deployment for Call Center Agents
tSIP / MicroSIP + PowerShell + Config Server
Table of Contents
- Introduction
- Architecture Overview
- Prerequisites
- Part 1: Softphone Selection and Configuration
- Part 2: Locked-Down tSIP Configuration (ViciPhone)
- Part 3: MicroSIP Configuration Template
- Part 4: Bulk Config Generation
- Part 5: Deployment Script (deploy-viciphone.ps1)
- Part 6: Watchdog Script
- Part 7: Uninstaller
- Part 8: Remote Deployment via WinRM
- Part 9: FastAPI Event Receiver
- Part 10: Testing Before Mass Rollout
- Part 11: Handling Updates
- Part 12: Monitoring Connection Quality
- Troubleshooting
- 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:
- Agent phones crash and don't restart. The agent doesn't notice (or doesn't care). They sit idle for hours. Calls go unanswered.
- Wrong codec settings cause transcoding. One phone negotiating G.711 u-law when the trunk expects a-law means your Asterisk server is burning CPU on needless transcoding. Multiply by 40 agents and you've got quality problems.
- Agents change settings they shouldn't. They discover the volume slider, then the codec list, then the SIP settings. Now they've broken their own phone and it's your problem.
- No visibility into softphone state. You know an agent is "logged in" to ViciDial, but is their softphone actually registered? Is it connected to the right server? You have no idea until a customer complains about dead air.
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
- Call center IT administrators managing 10+ agent workstations
- VoIP administrators deploying softphones alongside Asterisk, FreeSWITCH, or ViciDial
- Managed Service Providers (MSPs) supporting multiple call center clients
- Anyone tired of manually configuring softphones one machine at a time
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:
- You generate per-extension config files from a template (one
.iniper SIP extension). - The deployment script copies the softphone binary + config + watchdog to each agent machine.
- The watchdog runs as a scheduled task, restarting the softphone if it crashes.
- The softphone UI is locked down -- agents cannot access settings, change codecs, or modify SIP credentials.
- Softphone events (registration, calls, errors) are posted to a central FastAPI receiver for monitoring.
Prerequisites
On the deployment/config server (Linux or Windows):
- Python 3.8+ (for the FastAPI event receiver and config generation)
- PowerShell 7+ (for cross-platform script execution) or Windows PowerShell 5.1
- Network access to agent machines on WinRM port (TCP 5985/5986)
On each agent Windows machine:
- Windows 10/11 or Windows Server 2016+
- WinRM enabled (for remote deployment)
- .NET Framework 4.5+ (ships with Windows 10)
- Network access to SIP server (UDP 5060, RTP range 10000-20000)
Software to download:
- tSIP -- portable, no installer needed, highly configurable
- MicroSIP -- lightweight, installer-based, simpler config
- Both are free for commercial use.
SIP infrastructure:
- Asterisk / FreeSWITCH / ViciDial server with SIP extensions provisioned
- Extension credentials (extension number, password, SIP server address)
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:
hide_settings: true-- removes the Settings menu entirelyhide_main_menu: true-- hides the top menu barhide_dialpad: true-- agents in a dialer environment should not be manually dialinghide_view: trueandhide_tools: true-- removes remaining menu items
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:
- tSIP launches and registers to the SIP server
- The UI is locked (no settings menu, no dialpad)
- Auto-answer works (call the extension from another phone)
- The watchdog restarts tSIP after you kill it (
taskkill /f /im tSIP.exe) - The desktop shortcut works
- tSIP starts automatically after a reboot
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:
- The deployment completed without WinRM errors
- The file share path was accessible from the agent machine
- Audio quality is acceptable (make a test call, listen for one-way audio, echo, packet loss)
Step 3: Small Batch (5 Machines)
Deploy to 5 machines. Monitor the event receiver dashboard for 30 minutes. Look for:
- All 5 show
registration: registered - No watchdog restart events
- Audio quality across all 5 is clean
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.)
- Regenerate configs:
python3 generate_configs.py --format tsip --output ./configs/tsip/ - 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
- Download new tSIP/MicroSIP binary
- Place it in the
\\DEPLOY_SERVER\viciphone\bin\share - 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:
- Add a JSON datasource pointing to
http://MONITOR_SERVER:8090 - Create a dashboard with panels:
- Registered Hosts -- query
/hosts, count where detail contains "registered" - Watchdog Restarts (24h) -- query
/status, displaywatchdog_restarts_24h - Recent Alerts -- query
/events/recent?event_type=watchdog&limit=20
- Registered Hosts -- query
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
- Check credentials: Verify extension/password match what Asterisk expects
- Check connectivity:
Test-NetConnection -ComputerName SIP_SERVER -Port 5060 - Check firewall: Ensure UDP 5060 (SIP) and UDP 10000-20000 (RTP) are open
- Check Asterisk:
asterisk -rx "sip show peers"-- is the extension showing up?
Audio Issues After Deployment
- Codec mismatch: Verify
PRIMARY_CODECmatches your trunk. Runasterisk -rx "core show codecs"andasterisk -rx "sip show peer EXTENSION"to confirm. - 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_maxranges. - Audio device selection: If the machine has multiple audio devices (common with VMs), MicroSIP may pick the wrong one. Set
audioInputDeviceandaudioOutputDeviceexplicitly in the config.
Watchdog Running But Not Restarting
- Check the watchdog log:
type C:\ViciPhone\watchdog\watchdog.log - If you see "Restart limit reached," the softphone is crash-looping. Check the tSIP log for the root cause.
- 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:
- Configuration design -- locked-down tSIP and MicroSIP templates with correct codec priority, auto-answer, SRTP, and UI lockdown.
- Bulk generation -- Python script that reads a CSV and produces one config file per extension, for either softphone type.
- 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.
- Watchdog -- Scheduled task that checks every 2 minutes and restarts the softphone if it has crashed, with rate limiting to prevent restart storms.
- Clean uninstallation -- Removes everything: process, scheduled task, registry entry, shortcut, and install directory.
- Remote mass deployment -- WinRM-based script that deploys to all machines in a CSV, with connectivity pre-checks, error handling, and a summary report.
- 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.