Files
edncfix/eDNC-SpecialCharFix.ps1
cproudlock 2748bfa037 v1.6.0: Switch to user-logon task, migrate API to tsgwp00525 FQDN
- Install-ScheduledTask.ps1: run at user logon with visible UI (was startup
  with no UI), hardcode install path to C:\eDNC-Fix
- Migrate ShopDB API URL from http://geitshopdb/api.asp to
  https://tsgwp00525.rd.ds.ge.com/shopdb/api.asp in code and docs
- eDNC-SpecialCharFix.ps1: ~250 line rework (see diff)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:03:50 -04:00

628 lines
23 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
eDNC Special Character Fix - Real-time file watcher to strip invalid characters from DNC files.
.DESCRIPTION
Monitors a specified folder for DNC program files (.pun, .nc, etc.) and automatically
removes special characters (0xFF and others) that cause issues with CNC machines.
Some legacy DNC systems and communication protocols add padding or termination bytes
that modern CNC controllers cannot process correctly.
.PARAMETER WatchFolder
The folder to monitor for DNC files. Default: C:\Dnc_Files\Q
.PARAMETER FileFilter
File pattern to watch. Default: *.pun
.PARAMETER IncludeSubfolders
Whether to watch subfolders. Default: $true
.PARAMETER CharactersToRemove
Array of byte values to strip from files. Default: @(255) for 0xFF
.EXAMPLE
.\eDNC-SpecialCharFix.ps1
Runs with default settings, watching C:\Dnc_Files\Q for *.pun files
.EXAMPLE
.\eDNC-SpecialCharFix.ps1 -WatchFolder "D:\DNC\Programs" -FileFilter "*.nc"
Watches D:\DNC\Programs for .nc files
.EXAMPLE
.\eDNC-SpecialCharFix.ps1 -CharactersToRemove @(255, 0, 26)
Removes 0xFF, NULL, and SUB (Ctrl+Z) characters
.NOTES
Author: GE Aerospace - Rutland
Version: 1.6.0
Date: 2025-12-12
v1.6.0 - Auto-update loop fix:
- Fixed update loop when running as standard user (can't write to C:\eDNC-Fix)
- Added -ErrorAction Stop to Copy-Item to properly catch permission errors
- Verify version.ini was actually updated before restarting
- Disable further update checks after failure to prevent retry loop
v1.5.9 - Auto-update debugging:
- Check for updates immediately at startup (not just every 5 min)
- Added diagnostic output for update checks (shows source path, version comparison)
- Shows message when periodic update check runs
v1.5.8 - Update logging and event handler fix:
- Log "updated" event to API when auto-update occurs
- Fixed event handler to use synchronized hashtable instead of $script: variables
(event handlers run in separate runspaces where $script: is not accessible)
v1.5.7 - Improved file detection:
- Added Renamed event handler (some apps write temp then rename)
- Increased internal buffer to 64KB to prevent missed events
- Added debug logging for watcher configuration
v1.5.6 - Added explicit NotifyFilter for better file detection
v1.5.5 - Scheduled task now runs at logon with visible UI (for debugging)
v1.5.4 - Updated API URL to production server (tsgwp00525.rd.ds.ge.com)
v1.5.3 - PowerShell 5.1 compatibility fix:
- Replaced Join-String (PS 6+) with -join operator (PS 5.1 compatible)
v1.5.2 - Headless mode support:
- Detects when running without console (scheduled task with -WindowStyle Hidden)
- Logs to %TEMP%\eDNC-SpecialCharFix.log instead of console
- Fixed Install-ScheduledTask.ps1 to always use C:\eDNC-Fix path
- Added 60-second startup delay for scheduled task
v1.5.1 - Startup warning if PC not registered in ShopDB
v1.5.0 - API logging:
- Logs events (started, cleaned, ok, failed, error, stopped) to ShopDB API
- Tracks installations and stats per hostname
v1.4.0 - Auto-update:
- Checks S:\DT\cameron\eDNC-Fix\version.ini every 5 minutes
- Auto-downloads and restarts if newer version found
v1.3.0 - Fixed header UI:
- GE Aerospace ASCII banner stays at top
- Live status updates in header (Processing, Cleaned, Failed)
- Live stats counter (Cleaned/Failed count)
- Auto-elevate to Administrator via batch file
v1.2.0 - Immediate processing:
- Process file immediately when eDNC releases it (50ms initial delay)
- Aggressive retry: 100ms -> 200ms -> 400ms -> 800ms (15 attempts)
- Debouncing to prevent duplicate processing
- Exclusive file lock for atomic read/write
Common problematic characters:
- 0xFF (255) - Padding/fill character
- 0x00 (0) - NULL character
- 0x1A (26) - SUB/Ctrl+Z (EOF marker in some systems)
- 0x7F (127) - DEL character
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$WatchFolder = "C:\Dnc_Files\Q",
[Parameter()]
[string]$FileFilter = "*.pun",
[Parameter()]
[bool]$IncludeSubfolders = $true,
[Parameter()]
[int[]]$CharactersToRemove = @(255)
)
# Script info
$ScriptVersion = "1.6.0"
$ScriptName = "eDNC Special Character Fix"
# Detect headless mode (no console window - e.g., running as scheduled task with -WindowStyle Hidden)
$script:HeadlessMode = $false
try {
$null = [Console]::WindowWidth
} catch {
$script:HeadlessMode = $true
}
# Log file for headless mode
$script:LogFile = Join-Path $env:TEMP "eDNC-SpecialCharFix.log"
# Helper function for logging (works in both modes)
function Write-Log {
param([string]$Message, [string]$Color = "White")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
if ($script:HeadlessMode) {
Add-Content -Path $script:LogFile -Value "[$timestamp] $Message"
} else {
Write-Host $Message -ForegroundColor $Color
}
}
# Auto-update settings
$UpdateSource = "S:\DT\cameron\eDNC-Fix"
$LocalPath = "C:\eDNC-Fix"
$UpdateCheckInterval = 300 # Check every 5 minutes (in seconds)
$script:LastUpdateCheck = [DateTime]::MinValue
$script:UpdateDisabled = $false # Set to true after update failure to prevent retry loop
# API logging settings
$ApiUrl = "https://tsgwp00525.rd.ds.ge.com/shopdb/api.asp"
$script:Hostname = $env:COMPUTERNAME
# Header display function (skipped in headless mode)
function Show-Header {
param([int]$Cleaned = 0, [int]$Failed = 0, [string]$Status = "Watching...")
# Skip fancy UI in headless mode
if ($script:HeadlessMode) {
return 0
}
$headerLines = @(
""
" ____ ____ "
" / ___|| ___| / \ ___ _ __ ___ ___ _ __ __ _ ___ ___ "
"| | _ | _| / _ \ / _ \ '__/ _ \/ __| '_ \ / _`` |/ __/ _ \"
"| |_| || |___ / ___ \ __/ | | (_) \__ \ |_) | (_| | (_| __/"
" \____||_____| /_/ \_\___|_| \___/|___/ .__/ \__,_|\___\___|"
" |_| "
""
" $ScriptName"
" by Cam P. | v$ScriptVersion"
""
" Folder: $WatchFolder"
" Filter: $FileFilter | Cleaned: $Cleaned | Failed: $Failed"
""
" Status: $Status"
"================================================================"
)
# Save cursor, go to top, draw header
[Console]::SetCursorPosition(0, 0)
for ($i = 0; $i -lt $headerLines.Count; $i++) {
$line = $headerLines[$i]
# Clear line and write
Write-Host ("`r" + $line.PadRight([Console]::WindowWidth - 1)) -NoNewline
# Apply colors
[Console]::SetCursorPosition(0, $i)
if ($i -ge 1 -and $i -le 6) {
Write-Host $line -ForegroundColor Cyan
} elseif ($i -eq 8) {
Write-Host $line -ForegroundColor White
} elseif ($i -eq 9) {
Write-Host $line -ForegroundColor Gray
} elseif ($i -eq 14) {
if ($Status -like "*ERROR*" -or $Status -like "*FAILED*") {
Write-Host $line -ForegroundColor Red
} elseif ($Status -like "*CLEANED*") {
Write-Host $line -ForegroundColor Green
} else {
Write-Host $line -ForegroundColor Yellow
}
} elseif ($i -eq 15) {
Write-Host $line -ForegroundColor DarkGray
} else {
Write-Host $line
}
}
return $headerLines.Count
}
# Auto-update function
function Check-ForUpdate {
# Skip if updates were disabled (e.g., after permission failure)
if ($script:UpdateDisabled) {
return $false
}
$versionFile = Join-Path $UpdateSource "version.ini"
# Skip if source not available
if (-not (Test-Path $versionFile)) {
Write-Host " [UPDATE] Cannot access update source: $versionFile" -ForegroundColor DarkGray
return $false
}
try {
# Read version from INI file
$content = Get-Content $versionFile -Raw
if ($content -match 'Version=(\d+\.\d+\.\d+)') {
$remoteVersion = [version]$matches[1]
$localVersion = [version]$ScriptVersion
Write-Host " [UPDATE] Check: local=$localVersion, remote=$remoteVersion" -ForegroundColor DarkGray
if ($remoteVersion -gt $localVersion) {
return $remoteVersion.ToString()
}
}
else {
Write-Host " [UPDATE] Could not parse version from: $versionFile" -ForegroundColor DarkGray
}
}
catch {
Write-Host " [UPDATE] Error checking for update: $($_.Exception.Message)" -ForegroundColor DarkGray
}
return $false
}
function Invoke-Update {
param([string]$NewVersion)
$OldVersion = $ScriptVersion
Write-Host ""
Write-Host " [UPDATE] New version $NewVersion available!" -ForegroundColor Magenta
Write-Host " [UPDATE] Downloading update..." -ForegroundColor Magenta
try {
# Copy new files (use -ErrorAction Stop to catch permission errors)
$files = @("eDNC-SpecialCharFix.ps1", "Run-eDNCFix.bat", "Install-ScheduledTask.ps1", "version.ini")
foreach ($file in $files) {
$src = Join-Path $UpdateSource $file
$dst = Join-Path $LocalPath $file
if (Test-Path $src) {
Copy-Item -Path $src -Destination $dst -Force -ErrorAction Stop
}
}
# Verify update succeeded by checking local version.ini
$localVersionFile = Join-Path $LocalPath "version.ini"
$content = Get-Content $localVersionFile -Raw -ErrorAction Stop
if ($content -match 'Version=(\d+\.\d+\.\d+)') {
$updatedVersion = [version]$matches[1]
if ($updatedVersion -lt [version]$NewVersion) {
throw "Version file not updated (still at $updatedVersion)"
}
}
Write-Host " [UPDATE] Update complete! Restarting..." -ForegroundColor Magenta
# Log the update to API before restarting
Send-DNCEvent -EventType "updated" -Message "Updated from $OldVersion to $NewVersion"
Start-Sleep -Seconds 2
# Restart the script
$scriptPath = Join-Path $LocalPath "eDNC-SpecialCharFix.ps1"
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" -WindowStyle Normal
# Exit current instance
exit 0
}
catch {
Write-Host " [UPDATE] Update failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host " [UPDATE] Continuing with current version (updates disabled)..." -ForegroundColor Yellow
# Disable further update checks to prevent retry loop
$script:UpdateDisabled = $true
}
}
# API logging function
function Send-DNCEvent {
param(
[string]$EventType,
[string]$Filename = "",
[int]$BytesRemoved = 0,
[string]$Message = ""
)
# Skip if API is disabled (PC not registered or API unreachable)
if (-not $script:ApiEnabled) { return }
try {
$body = @{
action = "logDNCEvent"
hostname = $script:Hostname
filename = $Filename
eventType = $EventType
bytesRemoved = $BytesRemoved
version = $ScriptVersion
message = $Message
watchFolder = $WatchFolder
fileFilter = $FileFilter
}
$null = Invoke-WebRequest -Uri $ApiUrl -Method POST -Body $body -ContentType "application/x-www-form-urlencoded" -UseBasicParsing -TimeoutSec 5
}
catch {
# Silently fail - don't disrupt file watching for API issues
}
}
# Initialize console (or log file in headless mode)
if (-not $script:HeadlessMode) {
Clear-Host
$script:HeaderHeight = Show-Header -Status "Initializing..."
} else {
Write-Log "=== $ScriptName v$ScriptVersion started (headless mode) ==="
Write-Log "Watch folder: $WatchFolder | Filter: $FileFilter"
}
# Validate watch folder exists
if (-not (Test-Path $WatchFolder)) {
if (-not $script:HeadlessMode) {
Show-Header -Status "ERROR: Watch folder does not exist!"
[Console]::SetCursorPosition(0, $script:HeaderHeight + 1)
Write-Host "Please create the folder or specify a different path." -ForegroundColor Yellow
} else {
Write-Log "ERROR: Watch folder does not exist: $WatchFolder"
}
exit 1
}
# Update header with ready status
if (-not $script:HeadlessMode) {
Show-Header -Status "Watching for files... (Ctrl+C to stop)" | Out-Null
[Console]::SetCursorPosition(0, $script:HeaderHeight + 1)
Write-Host "Removing bytes: $($CharactersToRemove -join ', ') ($(($CharactersToRemove | ForEach-Object { '0x{0:X2}' -f $_ }) -join ', '))" -ForegroundColor DarkGray
Write-Host ""
} else {
Write-Log "Watching for files..."
}
# Log startup to API and check if PC is registered
$script:ApiEnabled = $true
try {
$body = @{
action = "logDNCEvent"
hostname = $script:Hostname
eventType = "started"
version = $ScriptVersion
message = "Script started"
}
$response = Invoke-WebRequest -Uri $ApiUrl -Method POST -Body $body -ContentType "application/x-www-form-urlencoded" -UseBasicParsing -TimeoutSec 5
$json = $response.Content | ConvertFrom-Json
if ($json.success -eq $false -and $json.error -match "Unknown hostname") {
$script:ApiEnabled = $false
Write-Log "WARNING: This PC ($($script:Hostname)) is not registered in ShopDB." "Yellow"
Write-Log " Activity will NOT be logged. Add this PC to ShopDB to enable logging." "Yellow"
}
}
catch {
# API unreachable - continue without logging
$script:ApiEnabled = $false
Write-Log "WARNING: Cannot reach ShopDB API. Activity will NOT be logged." "Yellow"
}
# Statistics
$script:FilesProcessed = 0
$script:BytesRemoved = 0
$script:FailedFiles = 0
# Debounce tracking - prevent processing same file multiple times
$script:RecentlyProcessed = @{}
$script:DebounceSeconds = 5
# Create file system watcher
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $WatchFolder
$watcher.Filter = $FileFilter
$watcher.IncludeSubdirectories = $IncludeSubfolders
# Increase buffer size to prevent missing events (default is 8KB, increase to 64KB)
$watcher.InternalBufferSize = 65536
# Watch for all relevant changes: file creation, modification, renames, size changes
$watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor `
[System.IO.NotifyFilters]::LastWrite -bor `
[System.IO.NotifyFilters]::Size -bor `
[System.IO.NotifyFilters]::CreationTime
$watcher.EnableRaisingEvents = $true
Write-Log "FileSystemWatcher configured:" "Gray"
Write-Log " Path: $WatchFolder" "Gray"
Write-Log " Filter: $FileFilter" "Gray"
Write-Log " Buffer: $($watcher.InternalBufferSize) bytes" "Gray"
Write-Log " EnableRaisingEvents: $($watcher.EnableRaisingEvents)" "Gray"
# List existing files matching filter for verification
$existingFiles = Get-ChildItem -Path $WatchFolder -Filter $FileFilter -ErrorAction SilentlyContinue
Write-Log " Existing files matching filter: $($existingFiles.Count)" "Gray"
if ($existingFiles.Count -gt 0) {
$existingFiles | ForEach-Object { Write-Log " - $($_.Name)" "DarkGray" }
}
# Define the cleanup action (simplified - no $script: vars, they don't work in event handlers)
$action = {
try {
$path = $Event.SourceEventArgs.FullPath
$changeType = $Event.SourceEventArgs.ChangeType
$fileName = Split-Path $path -Leaf
$data = $Event.MessageData
# IMMEDIATE debug output to confirm event fired
Write-Host ""
Write-Host ">>> EVENT: $changeType - $fileName <<<" -ForegroundColor Cyan
# Get parameters from message data
$charsToRemove = $data.CharsToRemove
# Debounce using synchronized hashtable
$now = Get-Date
$lastProcessed = $data.State["LastProcessed_$path"]
if ($lastProcessed -and (($now - $lastProcessed).TotalSeconds -lt $data.DebounceSeconds)) {
Write-Host " [SKIP] Debounced" -ForegroundColor DarkGray
return
}
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Processing $fileName..." -ForegroundColor White
# Brief initial delay to let eDNC finish (50ms)
Start-Sleep -Milliseconds 50
# Try to acquire exclusive access
$maxRetries = 15
$retryCount = 0
$baseDelay = 100
while ($retryCount -lt $maxRetries) {
$fileStream = $null
try {
$fileStream = [System.IO.File]::Open(
$path,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None
)
# Read all bytes
$fileStream.Position = 0
$bytes = New-Object byte[] $fileStream.Length
$null = $fileStream.Read($bytes, 0, $bytes.Length)
$originalCount = $bytes.Length
# Remove specified bytes
$cleaned = [System.Collections.Generic.List[byte]]::new()
foreach ($b in $bytes) {
if ($b -notin $charsToRemove) {
$cleaned.Add($b)
}
}
$newCount = $cleaned.Count
if ($originalCount -ne $newCount) {
# Rewrite file
$fileStream.Position = 0
$fileStream.SetLength(0)
$cleanedArray = $cleaned.ToArray()
$fileStream.Write($cleanedArray, 0, $cleanedArray.Length)
$fileStream.Flush()
$removed = $originalCount - $newCount
Write-Host " [CLEANED] Removed $removed byte(s)" -ForegroundColor Green
$data.State["FilesProcessed"] = ([int]$data.State["FilesProcessed"]) + 1
$data.State["BytesRemoved"] = ([int]$data.State["BytesRemoved"]) + $removed
# Log to API (fire and forget)
try {
$body = @{ action="logDNCEvent"; hostname=$data.Hostname; filename=$fileName; eventType="cleaned"; bytesRemoved=$removed; version=$data.Version; message="Removed $removed byte(s)" }
$null = Invoke-WebRequest -Uri $data.ApiUrl -Method POST -Body $body -UseBasicParsing -TimeoutSec 5
} catch { }
} else {
Write-Host " [OK] No special characters found" -ForegroundColor Gray
}
# Mark as processed
$data.State["LastProcessed_$path"] = Get-Date
break
}
catch [System.IO.IOException] {
$retryCount++
if ($retryCount -lt $maxRetries) {
$delay = [math]::Min($baseDelay * [math]::Pow(2, $retryCount - 1), 16000)
Write-Host " [RETRY] File locked ($retryCount/$maxRetries)" -ForegroundColor Yellow
Start-Sleep -Milliseconds $delay
} else {
Write-Host " [FAILED] Could not access file after $maxRetries attempts" -ForegroundColor Red
$data.State["FailedFiles"] = ([int]$data.State["FailedFiles"]) + 1
}
}
catch {
Write-Host " [ERROR] $($_.Exception.Message)" -ForegroundColor Red
$data.State["FailedFiles"] = ([int]$data.State["FailedFiles"]) + 1
break
}
finally {
if ($fileStream) {
$fileStream.Close()
$fileStream.Dispose()
}
}
}
}
catch {
Write-Host " [ACTION ERROR] $($_.Exception.Message)" -ForegroundColor Red
}
}
# Synchronized state hashtable (accessible from event handlers)
$script:SharedState = [hashtable]::Synchronized(@{
FilesProcessed = 0
BytesRemoved = 0
FailedFiles = 0
})
# Create message data object to pass parameters to the action
$messageData = @{
CharsToRemove = $CharactersToRemove
DebounceSeconds = 5
FileFilter = $FileFilter
ApiUrl = $ApiUrl
Hostname = $script:Hostname
Version = $ScriptVersion
WatchFolder = $WatchFolder
State = $script:SharedState
}
# Register event handlers
$null = Register-ObjectEvent -InputObject $watcher -EventName Created -Action $action -MessageData $messageData
$null = Register-ObjectEvent -InputObject $watcher -EventName Changed -Action $action -MessageData $messageData
$null = Register-ObjectEvent -InputObject $watcher -EventName Renamed -Action $action -MessageData $messageData
# Error handler for watcher issues
$errorAction = {
Write-Host " [WATCHER ERROR] $($Event.SourceEventArgs.GetException().Message)" -ForegroundColor Red
}
$null = Register-ObjectEvent -InputObject $watcher -EventName Error -Action $errorAction
Write-Log "Event handlers registered: Created, Changed, Renamed, Error" "Green"
Write-Log ""
# Immediate update check at startup
Write-Log "Checking for updates from: $UpdateSource" "DarkGray"
$newVersion = Check-ForUpdate
if ($newVersion) {
Invoke-Update -NewVersion $newVersion
}
$script:LastUpdateCheck = Get-Date
Write-Log ""
Write-Log "Waiting for file events... (try copying a .pun file to test)" "Yellow"
# Keep script running
try {
while ($true) {
Start-Sleep -Seconds 1
# Periodic update check
$now = Get-Date
if (($now - $script:LastUpdateCheck).TotalSeconds -ge $UpdateCheckInterval) {
$script:LastUpdateCheck = $now
Write-Host " [UPDATE] Periodic check..." -ForegroundColor DarkGray
$newVersion = Check-ForUpdate
if ($newVersion) {
Invoke-Update -NewVersion $newVersion
}
}
}
}
finally {
# Log shutdown to API
Send-DNCEvent -EventType "stopped" -Message "Script stopped. Cleaned: $script:FilesProcessed, Failed: $script:FailedFiles"
# Cleanup on exit
$watcher.EnableRaisingEvents = $false
$watcher.Dispose()
Get-EventSubscriber | Unregister-Event
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Session Summary" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Files Cleaned: $script:FilesProcessed"
Write-Host " Bytes Removed: $script:BytesRemoved"
if ($script:FailedFiles -gt 0) {
Write-Host " Failed Files: $script:FailedFiles" -ForegroundColor Red
}
Write-Host ""
Write-Host "Stopped watching folder" -ForegroundColor Yellow
}