- 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>
487 lines
17 KiB
PowerShell
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
|
|
}
|