Files
edncfix/eDNC-SpecialCharFix.ps1
cproudlock 52a264ea0b v1.5.0: API logging to ShopDB
- Logs events (started, cleaned, ok, failed, error, stopped) to ShopDB API
- Tracks installations and stats per hostname in ednc_installations table
- Event history stored in ednc_logs table
- Added CLAUDE.md with project instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 08:55:20 -05:00

487 lines
17 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.5.0
Date: 2025-12-12
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.5.0"
$ScriptName = "eDNC Special Character Fix"
# 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
# API logging settings
$ApiUrl = "http://geitshopdb/api.asp"
$script:Hostname = $env:COMPUTERNAME
# Header display function
function Show-Header {
param([int]$Cleaned = 0, [int]$Failed = 0, [string]$Status = "Watching...")
$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 {
$versionFile = Join-Path $UpdateSource "version.ini"
# Skip if source not available
if (-not (Test-Path $versionFile)) {
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
if ($remoteVersion -gt $localVersion) {
return $remoteVersion.ToString()
}
}
}
catch {
# Silently fail if can't read
}
return $false
}
function Invoke-Update {
param([string]$NewVersion)
Write-Host ""
Write-Host " [UPDATE] New version $NewVersion available!" -ForegroundColor Magenta
Write-Host " [UPDATE] Downloading update..." -ForegroundColor Magenta
try {
# Copy new files
$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
}
}
Write-Host " [UPDATE] Update complete! Restarting..." -ForegroundColor Magenta
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
}
}
# API logging function
function Send-DNCEvent {
param(
[string]$EventType,
[string]$Filename = "",
[int]$BytesRemoved = 0,
[string]$Message = ""
)
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
Clear-Host
$script:HeaderHeight = Show-Header -Status "Initializing..."
# Validate watch folder exists
if (-not (Test-Path $WatchFolder)) {
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
exit 1
}
# Update header with ready status
Show-Header -Status "Watching for files... (Ctrl+C to stop)" | Out-Null
[Console]::SetCursorPosition(0, $script:HeaderHeight + 1)
Write-Host "Removing bytes: $($CharactersToRemove -join ', ') (0x$($CharactersToRemove | ForEach-Object { '{0:X2}' -f $_ } | Join-String -Separator ', 0x'))" -ForegroundColor DarkGray
Write-Host ""
# Log startup to API
Send-DNCEvent -EventType "started" -Message "Script started"
# 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
$watcher.EnableRaisingEvents = $true
# Define the cleanup action
$action = {
$path = $Event.SourceEventArgs.FullPath
$changeType = $Event.SourceEventArgs.ChangeType
$fileName = Split-Path $path -Leaf
# Get parameters from message data
$charsToRemove = $Event.MessageData.CharsToRemove
$debounceSeconds = $Event.MessageData.DebounceSeconds
$statusLine = $Event.MessageData.StatusLine
# Helper to update status line in header
$updateStatus = {
param($msg, $color = "Yellow")
$savedPos = [Console]::CursorTop
[Console]::SetCursorPosition(0, $statusLine)
Write-Host (" Status: " + $msg).PadRight([Console]::WindowWidth - 1) -ForegroundColor $color
[Console]::SetCursorPosition(0, $savedPos)
}
# Helper to update stats line in header
$updateStats = {
$savedPos = [Console]::CursorTop
[Console]::SetCursorPosition(0, 12)
Write-Host (" Filter: $($Event.MessageData.FileFilter) | Cleaned: $($script:FilesProcessed) | Failed: $($script:FailedFiles)").PadRight([Console]::WindowWidth - 1) -ForegroundColor White
[Console]::SetCursorPosition(0, $savedPos)
}
# Helper to log to API
$logToApi = {
param($eventType, $file, $bytes, $msg)
try {
$body = @{
action = "logDNCEvent"
hostname = $Event.MessageData.Hostname
filename = $file
eventType = $eventType
bytesRemoved = $bytes
version = $Event.MessageData.Version
message = $msg
watchFolder = $Event.MessageData.WatchFolder
fileFilter = $Event.MessageData.FileFilter
}
$null = Invoke-WebRequest -Uri $Event.MessageData.ApiUrl -Method POST -Body $body -ContentType "application/x-www-form-urlencoded" -UseBasicParsing -TimeoutSec 5
} catch { }
}
# Debounce check - skip if we processed this file recently
$now = Get-Date
if ($script:RecentlyProcessed.ContainsKey($path)) {
$lastProcessed = $script:RecentlyProcessed[$path]
if (($now - $lastProcessed).TotalSeconds -lt $debounceSeconds) {
return # Skip duplicate event
}
}
& $updateStatus "Processing: $fileName" "Yellow"
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | $changeType | $fileName" -ForegroundColor White
# Brief initial delay to let eDNC finish (50ms)
Start-Sleep -Milliseconds 50
# Try to acquire exclusive access - process immediately when available
$maxRetries = 15
$retryCount = 0
$baseDelay = 100 # Start with 100ms, doubles each retry (100, 200, 400, 800...)
while ($retryCount -lt $maxRetries) {
$fileStream = $null
try {
# Try to open file with exclusive access - this confirms it's available
$fileStream = [System.IO.File]::Open(
$path,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None
)
# Read all bytes while we have exclusive lock
$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
# Only rewrite if we found characters to remove
if ($originalCount -ne $newCount) {
# Truncate and rewrite
$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
$script:FilesProcessed++
$script:BytesRemoved += $removed
& $updateStatus "CLEANED: $fileName ($removed bytes)" "Green"
& $updateStats
& $logToApi "cleaned" $fileName $removed "Removed $removed byte(s)"
} else {
Write-Host " [OK] No special characters found" -ForegroundColor Gray
& $updateStatus "OK: $fileName (no changes)" "Gray"
& $logToApi "ok" $fileName 0 "No changes needed"
}
# Mark as recently processed
$script:RecentlyProcessed[$path] = Get-Date
break
}
catch [System.IO.IOException] {
$retryCount++
if ($retryCount -lt $maxRetries) {
# Exponential backoff: 100ms, 200ms, 400ms, 800ms...
$delay = [math]::Min($baseDelay * [math]::Pow(2, $retryCount - 1), 16000)
Write-Host " [RETRY] File locked, waiting $([math]::Round($delay/1000, 1))s... ($retryCount/$maxRetries)" -ForegroundColor Yellow
& $updateStatus "RETRY: $fileName ($retryCount/$maxRetries)" "Yellow"
Start-Sleep -Milliseconds $delay
} else {
Write-Host " [FAILED] Could not access file after $maxRetries attempts" -ForegroundColor Red
$script:FailedFiles++
& $updateStatus "FAILED: $fileName" "Red"
& $updateStats
& $logToApi "failed" $fileName 0 "Could not access file after $maxRetries attempts"
}
}
catch {
Write-Host " [ERROR] $($_.Exception.Message)" -ForegroundColor Red
$script:FailedFiles++
& $updateStatus "ERROR: $fileName" "Red"
& $updateStats
& $logToApi "error" $fileName 0 $_.Exception.Message
break
}
finally {
if ($fileStream) {
$fileStream.Close()
$fileStream.Dispose()
}
}
}
}
# Create message data object to pass parameters to the action
$messageData = @{
CharsToRemove = $CharactersToRemove
DebounceSeconds = $script:DebounceSeconds
StatusLine = 14 # Line number for status updates
FileFilter = $FileFilter
ApiUrl = $ApiUrl
Hostname = $script:Hostname
Version = $ScriptVersion
WatchFolder = $WatchFolder
}
# 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
# 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
$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
}