#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.4.0 Date: 2025-12-12 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.4.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 # 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 } } # 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 "" # 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) } # 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 } else { Write-Host " [OK] No special characters found" -ForegroundColor Gray & $updateStatus "OK: $fileName (no changes)" "Gray" } # 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 } } catch { Write-Host " [ERROR] $($_.Exception.Message)" -ForegroundColor Red $script:FailedFiles++ & $updateStatus "ERROR: $fileName" "Red" & $updateStats 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 } # 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 { # 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 }