From d3e11273f2467f6aff43341c9739e0d813c52dc2 Mon Sep 17 00:00:00 2001 From: MRoyPTC <66799739+MRoyPTC@users.noreply.github.com> Date: Mon, 18 May 2026 10:43:52 -0400 Subject: [PATCH 1/4] Delete Generic Use Cases/CSV Data Import directory To be replaced with File Mode --- Generic Use Cases/CSV Data Import/README.md | 72 ----- ... UDD for Unsolicited TCP Parser 202209.opf | Bin 11342 -> 0 bytes .../sample data/SAMPLE_DATA.csv | 23 -- .../scripts/csvfile_to_tcp_with_watchdog.py | 160 ---------- .../scripts/unsolicited_tcp_parser.js | 293 ------------------ 5 files changed, 548 deletions(-) delete mode 100644 Generic Use Cases/CSV Data Import/README.md delete mode 100644 Generic Use Cases/CSV Data Import/kepware project/OPF - UDD for Unsolicited TCP Parser 202209.opf delete mode 100644 Generic Use Cases/CSV Data Import/sample data/SAMPLE_DATA.csv delete mode 100644 Generic Use Cases/CSV Data Import/scripts/csvfile_to_tcp_with_watchdog.py delete mode 100644 Generic Use Cases/CSV Data Import/scripts/unsolicited_tcp_parser.js diff --git a/Generic Use Cases/CSV Data Import/README.md b/Generic Use Cases/CSV Data Import/README.md deleted file mode 100644 index b36d4e7..0000000 --- a/Generic Use Cases/CSV Data Import/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# CSV Data Import with Kepware Universal Device Driver - -This example provides a reference architecture for how to build a custom solution to monitor file values in CSV format and push the data in JSON format to a listener profile for UDD. - -While this example uses a Python script as the file watcher and processor, the file solution could be built with any programming languge to monitor and process the CSV data. - -## Requirements - -- Kepware versions 6.11 or higher that support the UDD v2.0 profile -- For CSV watcher, Python 3+ is required with [watchdog](https://github.com/gorakhargosh/watchdog) package installation - -## Quick Start - -1. Load Kepware project file (project file already includes Javascript for Universal Device Driver) -2. Edit the Python script variables to identify FILEPATH, FILENAME and COLUMN_KEY variables. -3. Manually create tags in Kepware per incoming CSV file (see below for instructions) -4. Launch Quick Client from the Kepware Configuration tool -5. Run the Python script (recommend using Task Scheduler or create a service for managed runtime) on same host as Kepware for testing purposes -6. Create/modify/overwrite target CSV file in target directory -7. Ensure correct values are seen for tags within Quick Client - -## Python and Javascript (UDD Profile) Overview - -The Python script connects to companion Kepware UDD Javascript profile on the specified IP and TCP port and watches a target directory for a CSV file of a specific name. When the CSV file created, modified or overwritten, the script parses the contents and sends a JSON formatted test to a listening UDD profile. - -The script expects CSV formats like: - -|Column1|Column2|Column3| -| :--: | :--: | :--: | -|string1|p1|50| -|string2|p2|77| - -CSV text: -``` -column1,column2,column3 -string1,p1,50 -string2,p2,77 -``` - -After detecting a change, the Python script sends the data grouped by fields in one selected column (e.g. column2) to the configured Kepware UDD channel in a JSON blob as below: - -```json -{ - "p1": { - "column1": "string1", - "column2": "p1", - "column3": "50" - }, - "p2": { - "column1": "string2", - "column2": "p2", - "column3": "77" - } -} -``` - -## Tag Configuration - -Tag addressing uses leverages the source CSV header names to define which column value you want to monitor in a tag. - -### Addressing Syntax - -The data can then be accessed in Kepware from the Universal Device Driver using the following tag address syntax: - -Format: *key_column_name.column_name* - -Below are examples based on the above data set: - -|Tag Address|Value from Example| -| :----------: | :----------: | -| p1.column1 | string1 | -| p2.column3| 77 | diff --git a/Generic Use Cases/CSV Data Import/kepware project/OPF - UDD for Unsolicited TCP Parser 202209.opf b/Generic Use Cases/CSV Data Import/kepware project/OPF - UDD for Unsolicited TCP Parser 202209.opf deleted file mode 100644 index 97af3fb8d1da15b792a4961ef24f67bfd1e1c629..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11342 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 diff --git a/Generic Use Cases/CSV Data Import/sample data/SAMPLE_DATA.csv b/Generic Use Cases/CSV Data Import/sample data/SAMPLE_DATA.csv deleted file mode 100644 index d074ab3..0000000 --- a/Generic Use Cases/CSV Data Import/sample data/SAMPLE_DATA.csv +++ /dev/null @@ -1,23 +0,0 @@ -Date,Time,Characteristic,Nominal,Upper_Tol,Lower_Tol,Actual,Deviation -31/05/2022,14:27:41,Machine_X_axis_temperature_Temp_Spec,9,1.25,-1.25,19.68,-0.32 -31/05/2022,14:27:41,Machine_X_axis_temperature_2_Temp_Spec,20,1.25,-1.25,19.73,-0.27 -31/05/2022,14:27:41,Machine_Y_axis_temperature_Temp_Spec,20,1.25,-1.25,19.32,-0.68 -31/05/2022,14:27:41,Machine_Y_axis_temperature_2_Temp_Spec,20,1.25,-1.25,19.5,-0.5 -31/05/2022,14:27:41,Machine_Z_axis_temperature_Temp_Spec,20,1.25,-1.25,19.61,-0.39 -31/05/2022,14:27:41,Machine_Z_axis_temperature_2_Temp_Spec,20,1.25,-1.25,19.61,-0.39 -31/05/2022,14:27:42,Machine_total_Rate_of_thermal_change_Temp_Spec,0,1,-1,0.18,0.18 -31/05/2022,14:27:42,Part_temperature_Temp_Spec,20,1,-1,19.56,-0.44 -31/05/2022,14:40:26,Bore_middle_Bore_size_tolerance,66.6815,0.0065,-0.0065,66.2892,-0.3923 -31/05/2022,14:39:46,Bore_top_Bore_size_tolerance,66.6815,0.0065,-0.0065,66.3447,-0.3368 -31/05/2022,14:41:08,Bore_bottom_Bore_size_tolerance,66.6815,0.0065,-0.0065,66.4474,-0.2341 -31/05/2022,14:43:49,Race_middle_Race_size,78.3019,0,0,78.6497,0.3478 -31/05/2022,14:43:50,Race_Cone_angle_tolerance,18.3667,0.0224,-0.0224,18.1645,-0.2021 -31/05/2022,14:43:50,Race_taper_Race_taper,0,0.0038,-0.0038,-0.0344,-0.0344 -31/05/2022,14:39:04,Top_face_Overall_height,38.265,0.165,-0.165,38.7623,0.4973 -31/05/2022,14:43:51,Bore_taper_value_Bore taper,0,0.013,-0.013,-0.1027,-0.1027 -31/05/2022,14:29:01,Line_along_left_carbide_strip_Straightness_tolerance,0,999,0,0.0018,0.0018 -31/05/2022,14:28:40,Line_along_right_carbide_strip_Straightness_tolerance,0,999,0,0.0033,0.0033 -31/05/2022,14:43:51,Fixture_face_Fixture_face_flatness,0,999,0,0,0 -31/05/2022,14:41:49,Small_rib_Small_rib_size,77.038,0.05,-0.05,77.0543,0.0163 -31/05/2022,14:43:48,Large_rib_Large_rib_size,90.526,0.05,-0.05,90.3535,-0.1725 -31/05/2022,14:43:52,Large_rib_width_LRW,6.096,0.051,-0.051,6.4798,0.3838 diff --git a/Generic Use Cases/CSV Data Import/scripts/csvfile_to_tcp_with_watchdog.py b/Generic Use Cases/CSV Data Import/scripts/csvfile_to_tcp_with_watchdog.py deleted file mode 100644 index 5650366..0000000 --- a/Generic Use Cases/CSV Data Import/scripts/csvfile_to_tcp_with_watchdog.py +++ /dev/null @@ -1,160 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) PTC Inc. and/or all its affiliates. All rights reserved. -# See License.txt in the project root for license information. -# -# -# Name: csvfile_to_tcp_with_watchdog.py -# Description: This script connects to companion Kepware Universal Device Driver profile -# to a specified IP and TCP port and watches a target directory for a CSV file of a specific name. -# -# When the CSV file created, modified or overwritten, the script parses contents and sends -# as plain text in a JSON format to a listening UDD profile. -# -# Expects CSV file formats like: -# -# column1,column2,column3 -# string1,p1,50 -# string2,p2,77 -# -# Sends the data grouped by one selected column (e.g. column2) to the -# configured Kepware Universal Device Driver: -# -# {"p1": {"column1": "string1", "column2": "p1", "column3": "50"}, -# "p2": {"column1": "string2", "column2": "p2", "column3": "77"}} -# -# The data can then be accessed in Kepware using the following tag syntax: -# -# p1.column1 -# p2.column3 -# -# MAKE SURE TO EDIT FILEPATH, FILENAME and COLUMN_KEY VARIABLES -# ---------------------------------------------------------------------------- - -import os -import csv -import socket -import time -import json -from watchdog.observers import Observer -from watchdog.events import PatternMatchingEventHandler - -# Define file path and file name to watch for -FILEPATH = './sample data' -FILENAME = FILEPATH + '/SAMPLE_DATA.csv' - -# Define Column name to use for data Key -COLUMN_KEY = 'Characteristic' - -# Define Universal Device Driver listening IP and port -UDDIP = '127.0.0.1' -UDDPORT = 60010 - -# Create a variable to store file modification time; this will be used while detecting real modification events -last_time_modified = 0 - -# Define any file name patterns using regex syntax to include or ignore -patterns = ["*"] -ignore_patterns = None -ignore_directories = False -case_sensitive = True - -def process(header, value, record): - key, other = header.partition('/')[::2] - if other: - process(other, value, record.setdefault(key, {})) - else: - record[key] = value - -def file_reader(): - time.sleep(.25) - filePath = FILENAME - data = {} - with open(filePath, newline='') as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - # Choose a column to group the data - data[row[COLUMN_KEY]] = record = {} - for header, value in row.items(): - process(header, value, record) - return(data) - -def data_sender(obj): - # Convert dict to string representation in order to make byte encoding easy - s_obj = json.dumps(obj) - # Encode as bytes and send to listening Kepware UDD profile - clientSocket.sendall(s_obj.encode()) - print(f"-- Data sent; data: {s_obj}") - -def on_created(event): - print(f"-- {event.src_path} has been created") - fileData = file_reader() - data_sender(fileData) - - # Update last modify time tracker - global last_time_modified - statbuf = os.stat(FILENAME) - last_time_modified = statbuf.st_mtime - -def on_deleted(event): - print(f"-- {event.src_path} has been deleted") - -def on_modified(event): - # Extra code here to handle the watchdog library bug around detecting too many modification events - global last_time_modified - # Get the most recent modification time of file - statbuf = os.stat(FILENAME) - modify_time = statbuf.st_mtime - # If the second modification event happens and the file modification time hasn't changed by more than .1 seconds, ignore it - if (modify_time - last_time_modified) > 0.1: - print(f"-- {event.src_path} has been modified") - fileData = {} - fileData = file_reader() - data_sender(fileData) - # Update the global variable with the current file access time for comparison during the second unexpected modificiation event - last_time_modified = modify_time - -def on_moved(event): - print(f"{event.src_path} has moved to {event.dest_path}") - -# Program Main -if __name__ == "__main__": - # Let user know we are starting - print('CSV to UDD File Watcher Starting') - time.sleep(.25) - - # Connect to UDD - clientSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - print('-- Attempting socket connection') - clientSocket.connect((UDDIP, UDDPORT)) - time.sleep(.25) - print('-- Socket established') - except Exception as e: - print ('-- Socket failed') - exit() - - # Create an event handler - my_event_handler = PatternMatchingEventHandler(patterns, ignore_patterns, ignore_directories, case_sensitive) - my_event_handler.on_created = on_created - my_event_handler.on_deleted = on_deleted - my_event_handler.on_modified = on_modified - my_event_handler.on_moved = on_moved - - # Define the Observer from the watchdog library and pass it the event handler, path and other arguments - go_recursively = False - my_observer = Observer() - my_observer.schedule(my_event_handler, FILEPATH, recursive=go_recursively) - - # Start the Observer - my_observer.start() - time.sleep(.5) - print('') - print('Watching for file..') - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - my_observer.stop() - my_observer.join() - - diff --git a/Generic Use Cases/CSV Data Import/scripts/unsolicited_tcp_parser.js b/Generic Use Cases/CSV Data Import/scripts/unsolicited_tcp_parser.js deleted file mode 100644 index cd2069c..0000000 --- a/Generic Use Cases/CSV Data Import/scripts/unsolicited_tcp_parser.js +++ /dev/null @@ -1,293 +0,0 @@ -/***************************************************************************** - * - * This file is copyright (c) 2022 PTC Inc. - * All rights reserved. - * - * Name: Unsolicited TCP Parser - * Description: Sets up a listening TCP server and parses expected data received - * into tags. Current configuration is for comma-seperated values modeled in JSON - * format by Python helper script. - * - * Notes: - * -- Currently configured for use with Python helper script - * ---- "csvfile_to_tcp_with_watchdog.py" - * -- If not using Python helper script, users must customize tag parsing function - * ---- based on incoming TCP data - * - * Developed on Kepware Server version 6.11, UDD V2.0 - * - * Version: 0.1.0 -******************************************************************************/ -/** - * @typedef {string} MessageType - Type of communication "Read", "Write". - */ - -/** - * @typedef {string} DataType - KEPServerEx datatype "Default", "String", "Boolean", "Char", "Byte", "Short", "Word", "Long", "DWord", "Float", "Double", "BCD", "LBCD", "Date", "LLong", "QWord". - */ - -/** - * @typedef {number[]} Data - Array of data bytes. Uint8 byte array. - */ - -/** - * @typedef {object} Tag - * @property {string} Tag.address - Tag address. - * @property {DataType} Tag.dataType - Kepserver data type. - * @property {boolean} Tag.readOnly - Indicates permitted communication mode. - */ - - /** - * @typedef {object} CompleteTag - * @property {string} Tag.address - Tag address. - * @property {*} Tag.value - (optional) Tag value. - */ - -/** - * @typedef {object} OnProfileLoadResult - * @property {string} version - Version of the driver. - * @property {string} mode - Operation mode of the driver "Client", "Server". - */ - - /** - * @typedef {object} OnValidateTagResult - * @property {string} address - (optional) Fixed up tag address. - * @property {DataType} dataType - (optional) Fixed up Kepserver data type. Required if input dataType is "Default". - * @property {boolean} readOnly - (optional) Fixed up permitted communication mode. - * @property {boolean} valid - Indicates address validity. - */ - -/** - * @typedef {object} OnTransactionResult - * @property {string} action - Action of the operation: "Complete", "Receive", "Fail". - * @property {CompleteTag[]} tags - Array of tags (if any active) to complete. Undefined indicates tag is not complete. - * @property {Data} data - The resulting data (if any) to send. Undefined indicates no data to send. - */ - - - -/** Global variable for driver version */ -const VERSION = "2.0"; - -/** Global variable for driver mode */ -const MODE = "Server" - -/** Status types */ -const ACTIONRECEIVE = "Receive" -const ACTIONCOMPLETE = "Complete" -const ACTIONFAILURE = "Fail" - -// Global variable for all Kepware supported data_types -const data_types = { - DEFAULT: "Default", - STRING: "String", - BOOLEAN: "Boolean", - CHAR: "Char", - BYTE: "Byte", - SHORT: "Short", - WORD: "Word", - LONG: "Long", - DWORD: "DWord", - FLOAT: "Float", - DOUBLE: "Double", - BCD: "BCD", - LBCD: "LBCD", - LLONG: "LLong", - QWORD: "QWord" -} - -/** - * Logging Level System tag - control logging level from client application - * This can be used to avoid logging verbose SDS protocol messages unless - * needed for debugging - */ - - const LOGGING_LEVEL_TAG = { - address: "LoggingLevel", - dataType: data_types.WORD, - readOnly: false, -} -const STD_LOGGING = 0; -const VERBOSE_LOGGING = 1; -const DEBUG_LOGGING = 2; -// Sets initial Logging Level -const LOGGING_LEVEL = STD_LOGGING; - -/** Captures the global log function so that it can be wrapped **/ -let originalLogFunction = log; -log = function (msg, level = STD_LOGGING) { - switch (readFromCache(LOGGING_LEVEL_TAG.address).value) { - case VERBOSE_LOGGING: - if (level <= VERBOSE_LOGGING) { - originalLogFunction(msg); - } - break; - case DEBUG_LOGGING: - if (level <= DEBUG_LOGGING) { - originalLogFunction(msg); - } - break; - default: - if (level == STD_LOGGING) { - originalLogFunction(msg); - } - break; - } -} - -/** - * Retrieve driver metadata. - * - * @return {OnProfileLoadResult} - Driver metadata. - */ -function onProfileLoad() { - /* Initialize our internal global cache to store topic PUBLISH responses */ - initializeCache(); - - // Initialize LoggingLevel control - writeToCache(LOGGING_LEVEL_TAG.address, LOGGING_LEVEL); - - return { version: VERSION, mode: MODE }; -} - - /** - * Validate an address. - * - * @param {object} info - Object containing the function arguments. - * @param {Tag} info.tag - Single tag. - * - * @return {OnValidateTagResult} - Single tag with a populated '.valid' field set. - */ -function onValidateTag(info) { - - // Check if it's LoggingLevel tag - if (info.tag.address === LOGGING_LEVEL_TAG.address) { - info.tag = validateLoggingTag(info.tag) - log('onValidateTag - address "' + info.tag.address + '" is valid.', DEBUG_LOGGING) - return info.tag; - } - - // If tag is left with "Default" data type convert to String type and validate address as long as length is not null - if (info.tag.dataType == data_types.DEFAULT) { - info.tag.dataType = data_types.STRING - info.tag.valid = true; - return info.tag; - } - - // If not Default, respect configured data type and validate address as long as length is not null - else { - info.tag.valid = true; - return info.tag; - } -} - -/** - * Handle request for a tag to be completed. - * - * @param {object} info - Object containing the function arguments. - * @param {MessageType} info.type - Communication mode for tags. Can be undefined. - * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. - * - * @return {OnTransactionResult} - The action to take, tags to complete (if any) and/or data to send (if any). - */ -function onTagsRequest(info) { - // Check if tag is LoggingLevel, update from cached value - if (info.tags[0].address === LOGGING_LEVEL_TAG.address){ - let returnAction = updateLoggingTag(info); - return returnAction; - } - - // Perform a lookup in our internal cache for the tag - const result = readFromCache(info.tags[0].address); - if (result.value !== undefined) { - info.tags[0].value = result.value; - - return { action: ACTIONCOMPLETE, tags: info.tags }; - } - - else { - info.tags[0].value = 0 - return { action: ACTIONCOMPLETE, tags: info.tags }; - } -} - -/** - * Handle incoming data. - * - * @param {object} info - Object containing the function arguments. - * @param {MessageType} info.type - Communication mode for tags. Can be undefined. - * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. - * @param {Data} info.data - The incoming data. - * - * @return {OnTransactionResult} - The action to take, tags to complete (if any) and/or data to send (if any). - */ -function onData(info) { - const inboundData = info.data; - - log(`ParseMessage - Received payload from TCP client: ${inboundData}`, DEBUG_LOGGING); - - // Convert the response to a string - let stringResponse = ""; - for (let i = 0; i < info.data.length; i++) { - stringResponse += String.fromCharCode(info.data[i]) - } - - log(`onData - String Response: ${stringResponse}`, VERBOSE_LOGGING) - - // Get the JSON body of the response - let jsonStr = stringResponse.substring(stringResponse.indexOf('{'), stringResponse.lastIndexOf('}') + 1 ); - - // Parse the JSON string and dump each feature's attribute-value pair into cache - let jsonObj = JSON.parse(jsonStr); - for(var feature in jsonObj){ - var featureObj = jsonObj[feature] - for (var attr in featureObj){ - var value = featureObj[attr] - log(`${feature}.${attr}` + '=' + value) - writeToCache(`${feature}.${attr}`, value) - } - } - - return { action: ACTIONCOMPLETE } -} - - - - -/** - * Helper Functions for Logging Tag functionality - */ - -/** - * Validate LoggingLevel tag - * @param {Tag} tag - * @returns {Tag} LoggingLevel Tag validation results - */ - - function validateLoggingTag(tag) { - if (tag.dataType === data_types.DEFAULT){ - tag.dataType = data_types.WORD - } - tag.readOnly = false; - tag.valid = true; - - return tag -} - -/** - * Update the Logging tag to either read the value or modify the level. - * @param {info} info - * @returns {OnTransactionResult} Transaction Result for LoggingLevel Tag - */ -function updateLoggingTag(info) { - let value = undefined; - if (info.type === WRITE){ - writeToCache(LOGGING_LEVEL_TAG.address, info.tags[0].value) - return {action: ACTIONCOMPLETE} - } - else { - value = readFromCache(LOGGING_LEVEL_TAG.address).value - info.tags[0].value = value; - return { action: ACTIONCOMPLETE, tags: info.tags}; - } -} \ No newline at end of file From cd58bcfec20c6847fb60f2e5fcbfba9449db8f99 Mon Sep 17 00:00:00 2001 From: MRoyPTC <66799739+MRoyPTC@users.noreply.github.com> Date: Mon, 18 May 2026 10:50:13 -0400 Subject: [PATCH 2/4] Add files via upload --- .../FileReader_DirectoryWatch-profile.js | 672 ++++++++++++++++++ File Access/FileReader_DirectoryWatch.md | 202 ++++++ File Access/FileReader_FilePoll-profile.js | 537 ++++++++++++++ File Access/FileReader_FilePoll.md | 167 +++++ File Access/FileReader_TagImport.csv | 20 + File Access/README.md | 0 6 files changed, 1598 insertions(+) create mode 100644 File Access/FileReader_DirectoryWatch-profile.js create mode 100644 File Access/FileReader_DirectoryWatch.md create mode 100644 File Access/FileReader_FilePoll-profile.js create mode 100644 File Access/FileReader_FilePoll.md create mode 100644 File Access/FileReader_TagImport.csv create mode 100644 File Access/README.md diff --git a/File Access/FileReader_DirectoryWatch-profile.js b/File Access/FileReader_DirectoryWatch-profile.js new file mode 100644 index 0000000..980c312 --- /dev/null +++ b/File Access/FileReader_DirectoryWatch-profile.js @@ -0,0 +1,672 @@ +/************************************************************************************************************************ + * + * All rights reserved. + * + * FileReader-DirectoryWatch-profile.js + * Version 1.1 + * + * This profile will watch for changes (modify or add) in the directory that * is specified as the Base Path in + * Device Properties. Optionally, specific files or file types can be listed in User-Defined Settings. Tag values + * will be assigned according to the addresses below. This profile will work with various ASCII file types and + * can be used with CSV. + * + * Caveats: + * - Input file is limited to 200KB. + * + * Address examples (without quotes): + * 'file' - contents of entire file + * 'count' - number of lines in the file + * 'line#' - contents of the specified line. Example: line1 + * 'line#field#' - value of the specified field in the specified line. Line must be comma separated. Example: line3field4 + * 'lastline' - contents of the last non-empty line + * 'lastfield#' - value of the specified field in the last non-empty line. Example: lastfield4 + * + * User-Defined Settings > Input String example (comman seperated, without quotes): + * '*.csv,*.ini,readme.txt' - this will allow any csv or any ini file and a file named readme.txt + * '' or '*' - will allow any file + * + * TODO: + * Add option for file deleting when file is created (maybe ignore modified) + * + * Change Log: + * - v1.0 Initial release + * - v1.1 Changed to optionally not initialize tag values when no data is present, but instead set quality to Bad. Behavior + * is controlled by USEQUALITY constant at top of file. + * +************************************************************************************************************************/ +/** + * @typedef {string} MessageType - Type of tag(s) "Read", "Write". + */ + +/** + * @typedef {string} DataType - KEPServerEx datatype "Default", "String", "Boolean", "Char", "Byte", "Short", "Word", "Long", "DWord", "Float", "Double", "BCD", "LBCD", "Date", "LLong", "QWord". + */ + +/** + * @typedef {number[]} Data - Array of data bytes. Uint8 byte array. + */ + +/** + * @typedef {string} FileOperation - "OpenFile", + "CloseFile", + "ReadFile", + "WriteFile", + "ReadLine", + "WriteLine", + "AsyncWatchDir", + "AsyncUnWatchDir", + "CreateFile", + "DeleteFile" + * + * See File Operation Descriptions below for further detail + */ + +/** + * @typedef {string} FileOperationResult - "Success" if successful and error string if operation failed : + "Access denied", + "Bad path", + "Bad seek", + "Directory does not exist", + "Directory full", + "Directory path has syntax error", + "Directory path must be relative to Base Path", + "Directory path traversal not allowed", + "Disk full", + "End of file", + "File already closed", + "File already open", + "File is not open", + "File not found", + "File to be created already exists", + "File too large", + "Generic file exception", + "Hard IO", + "Internal error", + "Invalid file", + "Invalid open flags provided", + "Invalid position value provided", + "Lock violation", + "No active watch on specified directory", + "No file specified in path", + "Operation not supported for file opened in binary mode", + "Operation not supported for file opened in text mode", + "Path cannot end in a '.'", + "ReadLine only supports 1 data byte", + "Remove current directory", + "Sharing violation", + "Too many open files", + "Write data too large", + "WriteFile requires at least 1 data byte", + "WriteLine requires at least 2 data bytes", + */ + + /** + * @typedef {string} FileChange - "Created", "Deleted", "RenamedFrom", "RenamedTo", "Modified" + */ + +/** + * @typedef {object} Tag + * @property {string} Tag.address - Tag address. + * @property {DataType} Tag.dataType - Kepserver data type. + * @property {boolean} Tag.readOnly - Indicates permitted communication mode. + * @property {integer} Tag.bulkId - Integer that identifies the group into which to bulk the tag with other tags. + */ + + /** + * @typedef {object} CompleteTag + * @property {string} Tag.address - Tag address. + * @property {*} Tag.value - (optional) Tag value. + * @property {string} Tag.quality - (optional) Tag quality "Good", "Bad", or "Uncertain". + */ + +/** + * @typedef {object} OnProfileLoadResult + * @property {string} version - Version of the driver. + * @property {string} mode - Operation mode of the driver "Client", "Server", "File". + */ + + /** + * @typedef {object} OnValidateTagResult + * @property {string} address - (optional) Fixed up tag address. + * @property {DataType} dataType - (optional) Fixed up Kepserver data type. Required if input dataType is "Default". + * @property {boolean} readOnly - (optional) Fixed up permitted communication mode. + * @property {integer} bulkId - (optional) Integer that identifies the group into which to bulk the tag with other tags. + * Universal Device Driver assigns the next available bulkId, if undefined. If defined for one tag, + * must define for all tags. + * @property {boolean} valid - Indicates address validity. + */ + +/** + * @typedef {object} OnTransactionResult + * @property {string} action - Action of the operation: "Complete", "Receive", "Fail". + * @property {CompleteTag[]} tags - Array of tags (if any active) to complete. Undefined indicates tag is not complete. + * @property {Data} data - The resulting data (if any) to send. Undefined indicates no data to send. + */ + + /** + * @typedef {object} OnFileTransactionResult + * @property {string} action - (required) Action of the operation: "Complete", "Operate", "Fail". + * @property {CompleteTag[]} tags - (optional) Array of tags to complete if action "Complete" or "Fail". Undefined indicates tag is not complete. + * @property {FileOperation[]} operations - (optional) File operation to perform if action "Operate". Undefined indicates no operation to perform. + * @property {string} fileOrPath - (optional) Relative path of file or directory to operate on. Undefined indicates a file or path is not applicable. + * @property {Data} data - (optional) Data/arguments for the specified operation. For example, open flags for OpenFile, write data for WriteFile, etc. Undefined indicates no argument. + */ + +/** Global constants */ +const PROFILEVERSION = "2.0"; +const PROFILEMODE = "File"; +const MAXCACHESIZE = 65536; // 64kB + +// Tag Qualities +const QUALITYBAD = "Bad"; +const QUALITYGOOD = "Good" + +// Global variable for all Kepware supported data_types +const data_types = { + DEFAULT: "Default", + STRING: "String", + BOOLEAN: "Boolean", + CHAR: "Char", + BYTE: "Byte", + SHORT: "Short", + WORD: "Word", + LONG: "Long", + DWORD: "DWord", + FLOAT: "Float", + DOUBLE: "Double", + BCD: "BCD", + LBCD: "LBCD", + LLONG: "LLong", + QWORD: "QWord", + }; + +// File Actions +const ACTIONCOMPLETE = "Complete"; +const ACTIONFAILURE = "Fail"; +const ACTIONOPERATE = "Operate"; + +const READ = "Read" +const WRITE = "Write" + +// File Operations +const FILEOPERATIONS = { + OPENFILE: "OpenFile", + CLOSEFILE: "CloseFile", + READFILE: "ReadFile", + WRITEFILE: "WriteFile", + READLINE: "ReadLine", + WRITELINE: "WriteLine", + ASYNCWATCHDIR: "AsyncWatchDir", + ASYNCUNWATCHDIR: "AsyncUnWatchDir", + CREATEFILE: "CreateFile", + DELETEFILE: "DeleteFile" +} + +// Last Operation Results +const OPERATIONSUCCESSFUL = "Success"; + +// OpenFile Enumerations +// data[0] - Mode Byte +const OPENMODEREAD = 0; +const OPENMODEWRITE = 1; +const OPENMODEREADWRITE = 2; + +// data[1] - Type Byte +const OPENTYPEBINARY = 0; +const OPENTYPETEXT = 1; + +// data[2] - Access Byte +const OPENACCESSEXCLUSIVE = 0; +const OPENACCESSDENYWRITE = 1; +const OPENACCESSDENYREAD = 2; +const OPENACCESSDENYNONE = 3; + +// data[3] - Create Mode Byte +const OPENCREATEMODEOPENEXISTING = 0; +const OPENCREATEMODECREATETRUNCATE = 1; +const OPENCREATEMODECREATENOTRUNCATE = 2; + +// WriteLine Enumerations +// data[0] - File Position Byte +const WRITELINECURRENTPOSITION = 0; //Not supported +const WRITELINESEEKTOBEGIN = 1; //Not supported +const WRITELINESEEKTOEND = 2; + +// Valid bulk ID's for tags +const TAGTYPECSVDATA = 1; +const TAGTYPESYSTEM = 9999; + +/** + * Logging Level System tag - control logging level from client application + * This can be used to avoid logging verbose UDD log messages unless + * needed for debugging + */ +const LOGGING_LEVEL_TAG = { + address: "LoggingLevel", + dataType: data_types.WORD, + bulkId: TAGTYPESYSTEM, + readOnly: false, +}; + +const STD_LOGGING = 0; +const VERBOSE_LOGGING = 1; +const DEBUG_LOGGING = 2; + +// Sets initial Logging Level +const LOGGING_LEVEL = STD_LOGGING; + +/** Captures the global log function so that it can be wrapped **/ +let originalLogFunction = log; +log = function (msg, level = STD_LOGGING) { + switch (readFromCache(LOGGING_LEVEL_TAG.address).value) { + case VERBOSE_LOGGING: + if (level <= VERBOSE_LOGGING) { + originalLogFunction(msg); + } + break; + case DEBUG_LOGGING: + if (level <= DEBUG_LOGGING) { + originalLogFunction(msg); + } + break; + default: + if (level == STD_LOGGING) { + originalLogFunction(msg); + } + break; + } +} + +// State Machine States +const States = { + Initialize: 0, + WaitingForFile: 1, + FileOpened: 2, + FileRead: 3, + FileClosed: 4 +} +/** Global Constants */ +const USEQUALITY = false; // If true, tags with no data will show QUALITYBAD instead of initializing value to 0 or NULL + +/** Global Variables */ +var state = States.Initialize; +var currentFile = ""; +var storage = {}; // Because built-in cache is not large enough + +var validFiles + +/** + * Retrieve driver metadata. + * + * @return {OnProfileLoadResult} - Driver metadata. + */ +function onProfileLoad() { + + initializeCache(MAXCACHESIZE); + + // Initialize LoggingLevel control + writeToCache(LOGGING_LEVEL_TAG.address, LOGGING_LEVEL); + + return { version: PROFILEVERSION, mode: PROFILEMODE }; +} + +/** + * Validate a tag's address and populate relevant fields. + * + * @param {object} info - Object containing the function arguments. + * @param {Tag} info.tag - The tag to validate. + * @returns {OnValidateTagResult} - Tag object with the `.valid` field populated. + */ +function onValidateTag(info) { + const address = info.tag.address; + log(`onValidateTag called for address '${address}'`, VERBOSE_LOGGING); + + // Check if it's the special LoggingLevel tag + if (address === LOGGING_LEVEL_TAG.address) { + info.tag.dataType = LOGGING_LEVEL_TAG.dataType + info.tag.bulkId = LOGGING_LEVEL_TAG.bulkId + info.tag.valid = true; + + log(`onValidateTag - address "${address}" is valid.`, DEBUG_LOGGING); + return info.tag; + } + + try { + // Matches specific address patterns: + // - "file" or "linecount" or "lastline" + // - "line" followed by digits (e.g., "row1"), optionally followed by "field" and more digits (e.g., "row2col3") + // - "lastfield" followed by digits (e.g., "last10") + // Entire string must match exactly (no partial matches) + const regex = /^(file|linecount|lastline|line\d+(field\d+)?|lastfield\d+)$/; + + if (regex.test(address.toLowerCase())) { + + info.tag.bulkId = TAGTYPECSVDATA; + //info.tag.dataType = data_types.STRING; + info.tag.readOnly = true; + info.tag.address = address.toLowerCase() + info.tag.valid = true; + + log(`onValidateTag - address "${address}" is valid.`, DEBUG_LOGGING); + return info.tag; + } + + // Address did not match + info.tag.valid = false; + return info.tag; + + } catch (e) { + log(`UDD onValidateTag Unexpected error: ${e.message}`); + info.tag.valid = false; + return info.tag; + } +} + +/** +* Retrieve the new profile inputs from Device Properties -> User-Defined Settings -> Profile Inputs. +* +* @param {object} info - Object containing the value of the profile inputs +* @param {string} info.stringInput - Current value of input string property +* +* @return {OnProfileInputsChangeResult} - Whether the input was validated '.valid'. +*/ +function onProfileInputsChange(info) { + log(`onProfileInputsChange called with parameter '${info.stringInput}`, VERBOSE_LOGGING); + var input_string = info.stringInput; + + try { + validFiles = input_string.split(","); + + log("onProfileInputsChange successfully parsed Input String", VERBOSE_LOGGING); + return { valid: true }; + } + catch { + log("onProfileInputsChange failed to parse Input String", VERBOSE_LOGGING); + return { valid: false }; + } +} + +/** + * onFileTagsRequest: Handle file mode tag. + * + * @param {object} info - Object containing the function arguments. + * @param {MessageType} info.type - Read or write request. Can be undefined. + * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. + * + * @return {OnFileTransactionResult} - The action to take, tags to complete (if any) and/or data to process (if any). + */ +function onFileTagsRequest(info) { + + log(`onFileTagsRequest called with ${info.tags.length} tags of type ${info.tags[0].bulkId} in state ${state}.`, VERBOSE_LOGGING); + + //Logging tag + if (info.tags[0].bulkId === LOGGING_LEVEL_TAG.bulkId) { + let value = undefined; + if (info.type === WRITE){ + writeToCache(LOGGING_LEVEL_TAG.address, info.tags[0].value) + return { action: ACTIONCOMPLETE }; + } + else { + value = readFromCache(LOGGING_LEVEL_TAG.address).value + info.tags[0].value = value; + return { action: ACTIONCOMPLETE, tags: info.tags }; + } + } + + // CSV Data Tags + if (info.tags[0].bulkId=== TAGTYPECSVDATA) { + if (state == States.Initialize) { + state = States.WaitingForFile; + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.ASYNCWATCHDIR] }; + + } else { + for (let i = 0; i < info.tags.length; i++) { + const tag = info.tags[i]; + const result = storage[tag.address]; + if (result !== undefined) { + tag.value = result; + tag.quality = QUALITYGOOD; + //log(`Setting '${tag.address}' = '${tag.value}' from storage.`, VERBOSE_LOGGING); + } else { + //log(`No data in storage for '${tag.address}', setting QUALITYBAD.`, VERBOSE_LOGGING); + if (USEQUALITY){ + tag.quality = QUALITYBAD; //rely on quality + } else { + if (tag.dataType === data_types.STRING){ //or initalize the value with 0 or NULL + tag.value = ""; + } else { + tag.value = 0; + } + tag.quality = QUALITYGOOD; + } + + } + } + return { action: ACTIONCOMPLETE, tags: info.tags } + } + } + + log(`Error: Unexpected tag type in onFileTagsRequest '${info.tags[0].bulkId}'`); + state = States.Initialize; + return { action: ACTIONFAILURE }; +} + +/** + * onFileOperations: Handle single file operation result. + * + * @param {object} info - Object containing the function arguments. + * @param {MessageType} info.type - Read or write request. Can be undefined. + * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. + * @param {FileOperation} info.lastOperation - Last file operation performed + * @param {FileOperationResult} info.lastOperationResult - Last file operation result + * @param {string} info.lastFileOrPath - Relative path of last file or directory operated on + * + * @return {OnFileTransactionResult} - The action to take, tags to complete (if any) and/or data to send (if any). + */ +function onFileOperations(info) { + + log(`onFileOperations called in state ${state}.`, VERBOSE_LOGGING); + currentFile = info.lastFileOrPath; + + if (info.lastOperation == FILEOPERATIONS.ASYNCWATCHDIR) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + state = States.Initialize; + log(`Failed to watch Base Path directory: ${info.lastOperationResult}`); // currentFile cannot in message be used as it is empty + return { action: ACTIONFAILURE }; + } + + log(`Operation '${info.lastOperation}' on Base Path directory succeeded.`, VERBOSE_LOGGING); // currentFile cannot be used in message as it is empty + return { action: ACTIONCOMPLETE }; + } + + if (info.lastOperation === FILEOPERATIONS.OPENFILE) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + state = States.Initialize; + log(`Failed to open '${currentFile}': ${info.lastOperationResult}`); + return { action: ACTIONCOMPLETE }; // Or should this clear cache? + } + + state = States.FileRead; + log(`Operation '${info.lastOperation}' on '${currentFile}' succeeded.`, VERBOSE_LOGGING); + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.READFILE], fileOrPath: currentFile } + } + + if (info.lastOperation === FILEOPERATIONS.READFILE) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + state = States.Initialize; + log(`Failed to read '${currentFile}': ${info.lastOperationResult}`); + return { action: ACTIONCOMPLETE }; + } + + if (!info.data || info.data.length === 0) { + log(`No data received in '${currentFile}'`); + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.CLOSEFILE], fileOrPath: currentFile }; + } + + log(`Operation '${info.lastOperation}' on '${currentFile}' succeeded.`, VERBOSE_LOGGING); + log(`Read ${info.data.length} bytes from '${currentFile}'`, VERBOSE_LOGGING); + + const dataAsText = byteArrayToString(info.data); + const lines = dataAsText.split(/\r?\n/); // split on any combination of CR, LF or CRLF + + // Clear values in cache to prep for a CSV with less lines + if (USEQUALITY){ + for (var each in storage) delete storage[each] //This will show bad quality with previous values in the extra tags since the client cannot access the addresses. + } else { + for (var each in storage){ // This will set the values to 0 or NULL + if (each.datatype === data_types.STRING){ + storage[each] = ""; + } else { + storage[each] = 0; + } + } + } + + // Set value for "file" tag + storage["file"]= dataAsText; + // Set value for "linecount" tag + storage["linecount"] = lines.length; + + for (let i = 0; i < lines.length; i++) { + const lineNumber = i + 1; + const sanitizedLine = lines[i].replace(/[^\x20-\x7E\n\r\t]/g, ''); // Remove non-printable characters from the line + + // Set values for "line" tags + storage[`line${lineNumber}`] = sanitizedLine; + + const columns = sanitizedLine.split(','); + for (let j = 0; j < columns.length; j++) { + const colNumber = j + 1; + // Set values for "line/field" tags + storage[`line${lineNumber}field${colNumber}`] = columns[j]; + } + } + + const nonEmptyLines = lines.filter(str => !/^[\s\r\n]*$/.test(str)); // Remove all empty/whitespace lines + const lastLine = nonEmptyLines[nonEmptyLines.length - 1] || ""; + const lastValues = lastLine.split(','); + + // Set value for "lastline" tag (last line) + storage[`lastline`] = lastLine; + + for (let i = 0; i < lastValues.length; i++) { + // Set values for "lastfield" tags + storage[`lastfield${i + 1}`] = lastValues[i]; + } + + state = States.FileClosed; + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.CLOSEFILE], fileOrPath: currentFile }; + } + + if (info.lastOperation === FILEOPERATIONS.CLOSEFILE) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + state = States.Initialize; + log(`Failed to close '${currentFile}': ${info.lastOperationResult}`); + return { action: ACTIONCOMPLETE }; + } + + log(`Operation '${info.lastOperation}' on '${currentFile}' succeeded.`, VERBOSE_LOGGING); + + state = States.WaitingForFile; + return { action: ACTIONCOMPLETE }; + } + + if (info.lastOperation === FILEOPERATIONS.DELETEFILE) { + + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + state = States.Initialize; + log(`Failed to delete '${currentFile}': ${info.lastOperationResult}`); + return { action: ACTIONCOMPLETE }; + } + + log(`Operation '${info.lastOperation}' on '${currentFile}' succeeded.`, VERBOSE_LOGGING); + + return { action: ACTIONCOMPLETE }; + } + + log(`Error: Unexpected state in onFileOperations '${state}'`, VERBOSE_LOGGING); + state = States.Initialize; + return { action: ACTIONFAILURE }; +} + +/** + * onFileChange: Asynchronously handle a change to a file or directory. + * + * @param {object} info - Object containing the function arguments. + * @param {MessageType} info.type - Communication mode for tags. Can be undefined. + * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. + * @param {FileChange} info.change - Reason file or path changed + * @param {string} info.fileOrPath - File or path that changed + * + * @return {OnFileTransactionResult} - The action to take, tags to complete (if any) and/or data to send (if any). + */ + +function onFileChange(info) { + // Handle asynchronous change to info.fileOrPath + log(`onFileChange called on file '${info.fileOrPath}' as '${info.change}'.`, VERBOSE_LOGGING); + + if (state == States.WaitingForFile && (info.change == "Modified" || info.change == "Created")) { + if (isValidFile(info.fileOrPath,validFiles)){ + state = States.FileOpened + log(`Opening '${info.fileOrPath}' from onFileChange.`, VERBOSE_LOGGING); + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.OPENFILE], fileOrPath: info.fileOrPath, data: [OPENMODEREAD, OPENTYPEBINARY, OPENACCESSDENYNONE, OPENCREATEMODECREATENOTRUNCATE] }; + } else { + // Ignoring invalid files + log(`Ignoring invalid file from onFileChange.`, VERBOSE_LOGGING); + return { action: ACTIONCOMPLETE } + + } + } else { + // Ignoring all other file or path changes + log(`Ignoring file or path change from onFileChange.`, VERBOSE_LOGGING); + return { action: ACTIONCOMPLETE } + } +} + +/** + * Helper function to translate bytes to string. + * + * Note: This function does not handle UTF-8 encoded multibyte characters! + */ +function byteArrayToString(data) { + return String.fromCharCode.apply(null, data); +} + +/** + * Helper function to translate string to bytes. + * + * Note: This function does not handle Unicode characters. + */ + function stringToByteArray(str) { + let byteArray = []; + for (let i = 0; i < str.length; i++) { + let char = str.charCodeAt(i) & 0xFF; + byteArray.push(char); + } + + // return an array of bytes + return byteArray; +} + +function isValidFile(filename, validPatterns) { + // If no patterns are provided, allow all files + if (!Array.isArray(validPatterns) || validPatterns.length === 0) { + return true; + } + const regexes = validPatterns.map(pattern => { + // Escape all regex special characters, except * and ? + const escaped = pattern.replace(/([.+^${}()|[\]\\])/g, '\\$1'); + + // Replace wildcard characters with regex equivalents and add "^" at the beginning and "$" at the end to ensure the pattern matches the entire filename + const regexPattern = '^' + + escaped + .replace(/\*/g, '.*') // * → any sequence + .replace(/\?/g, '.') // ? → single character + + '$'; + + // Build RegExp object with case-insensitive flag + return new RegExp(regexPattern, 'i'); + }); + + return regexes.some(regex => regex.test(filename)); +} diff --git a/File Access/FileReader_DirectoryWatch.md b/File Access/FileReader_DirectoryWatch.md new file mode 100644 index 0000000..68a1ca5 --- /dev/null +++ b/File Access/FileReader_DirectoryWatch.md @@ -0,0 +1,202 @@ +# FileReader Watch Directory Profile + +## Overview + +This Universal Device Driver (UDD) profile monitors a directory for file changes and reads the contents of matching files. It is designed for simple ASCII-based files, including CSV files, and exposes file data as tag values. + +When a file is created or modified in the configured directory, the profile reads the file and updates internal cache. Tags can then reference file content, line data, or specific fields. + +--- + +## Features + +- Monitors a directory for file **Created** and **Modified** events +- Supports filtering by file name or extension using wildcard patterns +- Reads file contents and exposes them as tags +- Parses CSV-style files into: + - Line-based values + - Field-based values +- Provides access to: + - Entire file contents + - Line count + - Specific lines and fields + - Last non-empty line and fields +- Optional handling of missing data via quality settings + +--- + +## Configuration + +### Base Path + +Set the directory to monitor using: + +**Device Properties → File Mode → Base Path** + +--- + +### User-Defined Settings + +**Device Properties → User-Defined Settings → Input String** + +Specify file filters using a comma-separated list: + +``` +*.csv,*.ini,readme.txt +``` + +Examples: + +- `*.csv` → All CSV files +- `*.txt` → All text files +- `readme.txt` → Specific file +- `*` or empty → All files + +Filtering is case-insensitive and supports `*` and `?` wildcards. + +--- + +## Supported Tag Addresses + +| Address | Description | +|----------------|-------------| +| `file` | Entire file contents | +| `linecount` | Number of lines in the file | +| `line#` | Contents of a specific line (e.g. `line1`) | +| `line#field#` | Specific field in a line (e.g. `line3field2`) | +| `lastline` | Last non-empty line | +| `lastfield#` | Field from the last non-empty line | + +### Notes + +- Line numbering starts at **1** +- Field numbering starts at **1** +- Fields are split using a comma delimiter (`,`) + +--- + +## Data Handling Behavior + +### File Processing + +When a file is read: + +1. The file is converted from byte array to string +2. The content is split into lines +3. Each line is stored: + - `line1`, `line2`, etc. +4. Each field is extracted: + - `line1field1`, `line1field2`, etc. +5. The last non-empty line is stored: + - `lastline`, `lastfield#` + +--- + +### Quality Handling + +Controlled by the `USEQUALITY` constant: + +```js +const USEQUALITY = false; +``` + +- **false (default)** + - Missing data is replaced with: + - Empty string for string tags + - `0` for numeric tags + - Quality is set to **Good** + +- **true** + - Missing data is not initialized + - Tag quality is set to **Bad** + +--- + +## Logging Control + +A system tag is available: + +``` +LoggingLevel +``` + +### Supported Levels + +| Value | Description | +|------|------------| +| 0 | Standard logging | +| 1 | Verbose logging | +| 2 | Debug logging | + +--- + +## File Monitoring Behavior + +- Directory monitoring is started using `AsyncWatchDir` +- The profile reacts to: + - `Created` + - `Modified` + +### File Processing Flow + +1. File is detected +2. File is opened (`OpenFile`) +3. File is read (`ReadFile`) +4. Data is parsed and stored +5. File is closed (`CloseFile`) + +--- + +## File Filtering + +Files are validated using wildcard patterns: + +- `*` → any sequence of characters +- `?` → single character + +Examples: + +- `*.csv` → matches all CSV files +- `data_??.txt` → matches `data_01.txt`, `data_AB.txt` + +If no filters are provided, all files are accepted. + +--- + +## Limitations + +- Maximum file size: **200 KB** +- Only supports ASCII-compatible text +- Does not support UTF-8 multibyte characters +- CSV parsing assumes: + - Comma-separated values + - No quoted or escaped delimiters + +--- + +## Known Considerations + +- File changes are event-driven +- Each new file read overwrites previously stored data +- Large or frequently updated files may increase processing frequency + +--- + +## Future Enhancements + +- Optional file deletion after processing +- Enhanced CSV parsing (quoted fields, edge cases) +- UTF-8 support +- Configurable delimiters + +--- + +## Summary + +This profile provides a lightweight mechanism for: + +- Monitoring directories +- Reading file contents +- Exposing structured data as tags + +It is well suited for simple CSV ingestion and file-based integrations without requiring a full ETL pipeline. \ No newline at end of file diff --git a/File Access/FileReader_FilePoll-profile.js b/File Access/FileReader_FilePoll-profile.js new file mode 100644 index 0000000..679bc11 --- /dev/null +++ b/File Access/FileReader_FilePoll-profile.js @@ -0,0 +1,537 @@ +/************************************************************************************************************************ + * + * All rights reserved. + * + * FileReader-FilePoll-profile.js + * Version 1.0 + * + * This profile will "poll" the file that is specified in Device Properties at the rate specified by the client + * application. Tag values will be assigned according to the addresses below. This profile will work with various + * ASCII file types and can be used with CSV. + * + * Caveats: + * - Input file is limited to 200KB. See https://thingworx.jira.com/browse/KEPA-85686 + * + * Address examples (without quotes): + * 'file' - contents of entire file + * 'count' - number of lines in the file + * 'line#' - contents of the specified line. Example: line1 + * 'line#field#' - value of the specified field in the specified line. Line must be comma separated. Example: line3field4 + * 'lastline' - contents of the last non-empty line + * 'lastfield#' - value of the specified field in the last non-empty line. Example: lastfield4 + * + * Change Log: + * - v1.0 Initial release + * +************************************************************************************************************************/ +/** + * @typedef {string} MessageType - Type of tag(s) "Read", "Write". + */ + +/** + * @typedef {string} DataType - KEPServerEx datatype "Default", "String", "Boolean", "Char", "Byte", "Short", "Word", "Long", "DWord", "Float", "Double", "BCD", "LBCD", "Date", "LLong", "QWord". + */ + +/** + * @typedef {number[]} Data - Array of data bytes. Uint8 byte array. + */ + +/** + * @typedef {string} FileOperation - "OpenFile", + "CloseFile", + "ReadFile", + "WriteFile", + "ReadLine", + "WriteLine", + "AsyncWatchDir", + "AsyncUnWatchDir", + "CreateFile", + "DeleteFile" + * + * See File Operation Descriptions below for further detail + */ + +/** + * @typedef {string} FileOperationResult - "Success" if successful and error string if operation failed : + "Access denied", + "Bad path", + "Bad seek", + "Directory does not exist", + "Directory full", + "Directory path has syntax error", + "Directory path must be relative to Base Path", + "Directory path traversal not allowed", + "Disk full", + "End of file", + "File already closed", + "File already open", + "File is not open", + "File not found", + "File to be created already exists", + "File too large", + "Generic file exception", + "Hard IO", + "Internal error", + "Invalid file", + "Invalid open flags provided", + "Invalid position value provided", + "Lock violation", + "No active watch on specified directory", + "No file specified in path", + "Operation not supported for file opened in binary mode", + "Operation not supported for file opened in text mode", + "Path cannot end in a '.'", + "ReadLine only supports 1 data byte", + "Remove current directory", + "Sharing violation", + "Too many open files", + "Write data too large", + "WriteFile requires at least 1 data byte", + "WriteLine requires at least 2 data bytes", + */ + + /** + * @typedef {string} FileChange - "Created", "Deleted", "RenamedFrom", "RenamedTo", "Modified" + */ + +/** + * @typedef {object} Tag + * @property {string} Tag.address - Tag address. + * @property {DataType} Tag.dataType - Kepserver data type. + * @property {boolean} Tag.readOnly - Indicates permitted communication mode. + * @property {integer} Tag.bulkId - Integer that identifies the group into which to bulk the tag with other tags. + */ + + /** + * @typedef {object} CompleteTag + * @property {string} Tag.address - Tag address. + * @property {*} Tag.value - (optional) Tag value. + * @property {string} Tag.quality - (optional) Tag quality "Good", "Bad", or "Uncertain". + */ + +/** + * @typedef {object} OnProfileLoadResult + * @property {string} version - Version of the driver. + * @property {string} mode - Operation mode of the driver "Client", "Server", "File". + */ + + /** + * @typedef {object} OnValidateTagResult + * @property {string} address - (optional) Fixed up tag address. + * @property {DataType} dataType - (optional) Fixed up Kepserver data type. Required if input dataType is "Default". + * @property {boolean} readOnly - (optional) Fixed up permitted communication mode. + * @property {integer} bulkId - (optional) Integer that identifies the group into which to bulk the tag with other tags. + * Universal Device Driver assigns the next available bulkId, if undefined. If defined for one tag, + * must define for all tags. + * @property {boolean} valid - Indicates address validity. + */ + +/** + * @typedef {object} OnTransactionResult + * @property {string} action - Action of the operation: "Complete", "Receive", "Fail". + * @property {CompleteTag[]} tags - Array of tags (if any active) to complete. Undefined indicates tag is not complete. + * @property {Data} data - The resulting data (if any) to send. Undefined indicates no data to send. + */ + + /** + * @typedef {object} OnFileTransactionResult + * @property {string} action - (required) Action of the operation: "Complete", "Operate", "Fail". + * @property {CompleteTag[]} tags - (optional) Array of tags to complete if action "Complete" or "Fail". Undefined indicates tag is not complete. + * @property {FileOperation[]} operations - (optional) File operation to perform if action "Operate". Undefined indicates no operation to perform. + * @property {string} fileOrPath - (optional) Relative path of file or directory to operate on. Undefined indicates a file or path is not applicable. + * @property {Data} data - (optional) Data/arguments for the specified operation. For example, open flags for OpenFile, write data for WriteFile, etc. Undefined indicates no argument. + */ + + +/** Global constants */ +const PROFILEVERSION = "2.0"; +const PROFILEMODE = "File"; +const MAXCACHESIZE = 65536; // 64kB + +// Tag Qualities +const QUALITYBAD = "Bad"; +const QUALITYGOOD = "Good" + +// Global variable for all Kepware supported data_types +const data_types = { + DEFAULT: "Default", + STRING: "String", + BOOLEAN: "Boolean", + CHAR: "Char", + BYTE: "Byte", + SHORT: "Short", + WORD: "Word", + LONG: "Long", + DWORD: "DWord", + FLOAT: "Float", + DOUBLE: "Double", + BCD: "BCD", + LBCD: "LBCD", + LLONG: "LLong", + QWORD: "QWord", + }; + +// File Actions +const ACTIONCOMPLETE = "Complete"; +const ACTIONFAILURE = "Fail"; +const ACTIONOPERATE = "Operate"; + +const READ = "Read" +const WRITE = "Write" + +// File Operations +const FILEOPERATIONS = { + OPENFILE: "OpenFile", + CLOSEFILE: "CloseFile", + READFILE: "ReadFile", + WRITEFILE: "WriteFile", + READLINE: "ReadLine", + WRITELINE: "WriteLine", + ASYNCWATCHDIR: "AsyncWatchDir", + ASYNCUNWATCHDIR: "AsyncUnWatchDir", + CREATEFILE: "CreateFile", + DELETEFILE: "DeleteFile" +} + +// Last Operation Results +const OPERATIONSUCCESSFUL = "Success"; + +// OpenFile Enumerations +// data[0] - Mode Byte +const OPENMODEREAD = 0; +const OPENMODEWRITE = 1; +const OPENMODEREADWRITE = 2; + +// data[1] - Type Byte +const OPENTYPEBINARY = 0; +const OPENTYPETEXT = 1; + +// data[2] - Access Byte +const OPENACCESSEXCLUSIVE = 0; +const OPENACCESSDENYWRITE = 1; +const OPENACCESSDENYREAD = 2; +const OPENACCESSDENYNONE = 3; + +// data[3] - Create Mode Byte +const OPENCREATEMODEOPENEXISTING = 0; +const OPENCREATEMODECREATETRUNCATE = 1; +const OPENCREATEMODECREATENOTRUNCATE = 2; + +// WriteLine Enumerations +// data[0] - File Position Byte +const WRITELINECURRENTPOSITION = 0; //Not supported +const WRITELINESEEKTOBEGIN = 1; //Not supported +const WRITELINESEEKTOEND = 2; + +// Valid bulk ID's for tags +const TAGTYPECSVDATA = 1; +const TAGTYPESYSTEM = 9999; + +/** + * Logging Level System tag - control logging level from client application + * This can be used to avoid logging verbose UDD log messages unless + * needed for debugging + */ + +const LOGGING_LEVEL_TAG = { + address: "LoggingLevel", + dataType: data_types.WORD, + bulkId: TAGTYPESYSTEM, + readOnly: false, +}; + +const STD_LOGGING = 0; +const VERBOSE_LOGGING = 1; +const DEBUG_LOGGING = 2; + +// Sets initial Logging Level +const LOGGING_LEVEL = STD_LOGGING; + +/** Captures the global log function so that it can be wrapped **/ +let originalLogFunction = log; +log = function (msg, level = STD_LOGGING) { + switch (readFromCache(LOGGING_LEVEL_TAG.address).value) { + case VERBOSE_LOGGING: + if (level <= VERBOSE_LOGGING) { + originalLogFunction(msg); + } + break; + case DEBUG_LOGGING: + if (level <= DEBUG_LOGGING) { + originalLogFunction(msg); + } + break; + default: + if (level == STD_LOGGING) { + originalLogFunction(msg); + } + break; + } +} + +// State Machine States +const States = { + Initialize: 0, +}; + +/** Global Variables */ +let state = States.Initialize; +let currentFile = ""; +let storage = {}; // Because built-in cache is not large enough + +/** + * Retrieve driver metadata. + * + * @return {OnProfileLoadResult} - Driver metadata. + */ +function onProfileLoad() { + // log(`onProfileLoad called`); + + initializeCache(MAXCACHESIZE); + + // Initialize LoggingLevel control + writeToCache(LOGGING_LEVEL_TAG.address, LOGGING_LEVEL); + + return { version: PROFILEVERSION, mode: PROFILEMODE }; +} + +/** + * Validate a tag's address and populate relevant fields. + * + * @param {object} info - Object containing the function arguments. + * @param {Tag} info.tag - The tag to validate. + * @returns {OnValidateTagResult} - Tag object with the `.valid` field populated. + */ +function onValidateTag(info) { + const address = info.tag.address; + log(`onValidateTag called for address '${address}'`, VERBOSE_LOGGING); + + // Check if it's the special LoggingLevel tag + if (address === LOGGING_LEVEL_TAG.address) { + info.tag.dataType = LOGGING_LEVEL_TAG.dataType + info.tag.bulkId = LOGGING_LEVEL_TAG.bulkId + info.tag.valid = true; + + log(`onValidateTag - address "${address}" is valid.`, DEBUG_LOGGING); + return info.tag; + } + + try { + // Matches specific address patterns: + // - "file" or "linecount" or "lastline" + // - "line" followed by digits (e.g., "row1"), optionally followed by "field" and more digits (e.g., "row2col3") + // - "lastfield" followed by digits (e.g., "last10") + // Entire string must match exactly (no partial matches) + const regex = /^(file|linecount|lastline|line\d+(field\d+)?|lastfield\d+)$/; + + if (regex.test(address.toLowerCase())) { + + info.tag.bulkId = TAGTYPECSVDATA; + info.tag.dataType = data_types.STRING; + info.tag.readOnly = true; + info.tag.address = address.toLowerCase() + info.tag.valid = true; + + log(`onValidateTag - address "${address}" is valid.`, DEBUG_LOGGING); + return info.tag; + } + // Address did not match + info.tag.valid = false; + return info.tag; + + } catch (e) { + log(`UDD onValidateTag Unexpected error: ${e.message}`); + info.tag.valid = false; + return info.tag; + } +} + +/** + * onFileTagsRequest: Handle file mode tag. + * + * @param {object} info - Object containing the function arguments. + * @param {MessageType} info.type - Read or write request. Can be undefined. + * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. + * + * @return {OnFileTransactionResult} - The action to take, tags to complete (if any) and/or data to process (if any). + */ +function onFileTagsRequest(info) { + log(`onFileTagsRequest called with ${info.tags.length} tags in state ${state}.`, VERBOSE_LOGGING); + + const tag = info.tags[0]; + //Logging tag + if (info.tags[0].bulkId === LOGGING_LEVEL_TAG.bulkId) { + let value = undefined; + if (info.type === READ){ + value = readFromCache(LOGGING_LEVEL_TAG.address).value + info.tags[0].value = value; + return { action: ACTIONCOMPLETE, tags: info.tags }; + } else { + writeToCache(LOGGING_LEVEL_TAG.address, info.tags[0].value) + return { action: ACTIONCOMPLETE }; + } + } + + // CSV Data Tags + if (tag.bulkId === TAGTYPECSVDATA) { + if (info.type === READ) { + if (state === States.Initialize) { + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.OPENFILE], data: [OPENMODEREAD, OPENTYPEBINARY, OPENACCESSDENYNONE, OPENCREATEMODECREATENOTRUNCATE] }; + } + } else { + //Ignore writes + return { action: ACTIONCOMPLETE }; + } + } + + log(`Error: Unexpected tag type in onFileTagsRequest '${info.tags[0].bulkId}'`); + state = States.Initialize; + return { action: ACTIONFAILURE }; +} + +/** + * onFileOperations: Handle single file operation result. + * + * @param {object} info - Object containing the function arguments. + * @param {MessageType} info.type - Read or write request. Can be undefined. + * @param {Tag[]} info.tags - Tags currently being processed. Can be undefined. + * @param {FileOperation} info.lastOperation - Last file operation performed + * @param {FileOperationResult} info.lastOperationResult - Last file operation result + * @param {string} info.lastFileOrPath - Relative path of last file or directory operated on + * + * @return {OnFileTransactionResult} - The action to take, tags to complete (if any) and/or data to send (if any). + */ +function onFileOperations(info) { + const tag = info.tags[0]; + const bulkId = tag.bulkId; + + log(`onFileOperations called with ${info.tags.length} tags in state ${state}.`, VERBOSE_LOGGING); + log(`Last operation: ${info.lastOperation} | Result: ${info.lastOperationResult}`, VERBOSE_LOGGING); + + currentFile = info.lastFileOrPath; + + if (info.lastOperation === FILEOPERATIONS.OPENFILE) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + log(`Failed to open '${currentFile}': ${info.lastOperationResult}`); + return { action: ACTIONCOMPLETE }; // Or should this clear cache? + } + + if (info.type === READ) { + log(`Opened '${currentFile}' successfully for READFILE (entire file).`, VERBOSE_LOGGING); + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.READFILE], fileOrPath: currentFile }; + } + } + + if (info.lastOperation === FILEOPERATIONS.READFILE) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + log(`Read from '${currentFile}' failed: ${info.lastOperationResult}`); + return { action: ACTIONCOMPLETE }; + } + + if (!info.data || info.data.length === 0) { + log("No data received in READFILE operation.", VERBOSE_LOGGING); + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.CLOSEFILE] }; + } + + log(`Read ${info.data.length} bytes from '${currentFile}'.`, VERBOSE_LOGGING); + + if (bulkId === TAGTYPECSVDATA) { + const dataAsText = byteArrayToString(info.data); + const lines = dataAsText.split(/\r?\n/); // split on any combination of CR, LF or CRLF + + // Clear values in cache to prep for a CSV with less lines + //for (var each in storage) storage[each] = ""; + for (var each in storage) storage[each] = 0; + // Or delete values in cache to prep for a CSV with less lines. This will show bad quality with previous values in the extra tags since the client cannot access the addresses. + //for (var each in storage) delete storage[each]; + + // Set value for "file" tag + storage["file"]= dataAsText; + // Set value for "linecount" tag + storage["linecount"] = lines.length; + + for (let i = 0; i < lines.length; i++) { + const lineNumber = i + 1; + const sanitizedLine = lines[i].replace(/[^\x20-\x7E\n\r\t]/g, ''); // Remove non-printable characters from the line + + // Set values for "line" tags + storage[`line${lineNumber}`] = sanitizedLine; + + const columns = sanitizedLine.split(','); + for (let j = 0; j < columns.length; j++) { + const colNumber = j + 1; + // Set values for "line/field" tags + storage[`line${lineNumber}field${colNumber}`] = columns[j]; + } + } + const nonEmptyLines = lines.filter(str => !/^[\s\r\n]*$/.test(str)); // Remove all empty/whitespace lines + const lastLine = nonEmptyLines[nonEmptyLines.length - 1] || ""; + const lastValues = lastLine.split(','); + + // Set value for "lastline" tag (last line) + storage[`lastline`] = lastLine; + + for (let i = 0; i < lastValues.length; i++) { + // Set values for "lastfield" tags + storage[`lastfield${i + 1}`] = lastValues[i]; + } + } + + return { action: ACTIONOPERATE, operations: [FILEOPERATIONS.CLOSEFILE] }; + } + + if (info.lastOperation === FILEOPERATIONS.CLOSEFILE) { + if (info.lastOperationResult !== OPERATIONSUCCESSFUL) { + log(`Close file failed for '${currentFile}': ${info.lastOperationResult}`, VERBOSE_LOGGING); + return { action: ACTIONCOMPLETE, tags: info.tags }; + } + + log(`Closed '${currentFile}' successfully.`, VERBOSE_LOGGING); + + if (bulkId === TAGTYPECSVDATA) { + for (let i = 0; i < info.tags.length; i++) { + const tag = info.tags[i]; + const result = storage[tag.address]; + if (result !== undefined) { + tag.value = result; + tag.quality = QUALITYGOOD; + //log(`Setting '${tag.address}' = '${tag.value}' from storage.`, VERBOSE_LOGGING); + } else { + tag.quality = QUALITYBAD; + //log(`No data in storage for '${tag.address}', setting QUALITYBAD.`, VERBOSE_LOGGING); + } + } + } + + return { action: ACTIONCOMPLETE, tags: info.tags }; + } + + log(`Error: Unexpected state in onFileOperations '${state}'`, VERBOSE_LOGGING); + state = States.Initialize; + return { action: ACTIONFAILURE }; +} + +/** + * Helper function to translate bytes to string. + * + * Note: This function does not handle UTF-8 encoded multibyte characters! + */ +function byteArrayToString(data) { + return String.fromCharCode.apply(null, data); +} + +/** + * Helper function to translate string to bytes. + * + * Note: This function does not handle Unicode characters. + */ + function stringToByteArray(str) { + let byteArray = []; + for (let i = 0; i < str.length; i++) { + let char = str.charCodeAt(i) & 0xFF; + byteArray.push(char); + } + + // return an array of bytes + return byteArray; +} diff --git a/File Access/FileReader_FilePoll.md b/File Access/FileReader_FilePoll.md new file mode 100644 index 0000000..d1e9388 --- /dev/null +++ b/File Access/FileReader_FilePoll.md @@ -0,0 +1,167 @@ +# FileReader Poll Profile + +Script File: FileReader_FilePoll.js + +## Overview + +This Universal Device Driver (UDD) profile reads a single file by polling it at the rate configured by the client application. It is designed for simple ASCII-based files, including CSV files, and exposes file content as tag values. + +Unlike directory-based monitoring, this profile does not react to file system events. Instead, it reads the file each time a client issues a read request. + +--- + +## Features + +- Polls a single file based on client read rate +- Reads and parses ASCII and CSV files +- Exposes file data through tag addresses +- Supports: + - Line-based access + - Field-based access + - Last line and field extraction +- Includes runtime logging control + +--- + +## Configuration + +### File Path + +Set the file to be read using: + +**Device Properties → File Mode → Base Path** +**Device Properties → File Mode → File Name** + +This profile operates on a single file and does not support directory monitoring. + +--- + +## Supported Tag Addresses + +| Address | Description | +|----------------|-------------| +| `file` | Entire file contents | +| `linecount` | Number of lines in the file | +| `line#` | Contents of a specific line (e.g. `line1`) | +| `line#field#` | Specific field in a line (e.g. `line3field2`) | +| `lastline` | Last non-empty line | +| `lastfield#` | Field from the last non-empty line | + +### Notes + +- Line numbering starts at **1** +- Field numbering starts at **1** +- Fields are split using a comma delimiter (`,`) + +--- + +## Data Handling Behavior + +### Polling Model + +- Data is read when a client issues a **read request** +- There is no background file monitoring +- Each read operation: + - Opens the file + - Reads its contents + - Parses and updates internal storage + - Returns values to the requesting tags + +--- + +### File Processing + +When the file is read: + +1. The file is converted from byte array to string +2. The content is split into lines +3. Each line is stored: + - `line1`, `line2`, etc. +4. Each field is extracted: + - `line1field1`, `line1field2`, etc. +5. The last non-empty line is stored: + - `lastline`, `lastfield#` + +--- + +### Data Reset Behavior + +Before processing a new file read: + +- Previously stored values are reset to `0` +- This ensures stale data is cleared if the new file contains fewer lines or fields + +Note: +- Tags with no matching data after parsing are returned with **Bad quality** + +--- + +## Logging Control + +A system tag is available: + +``` +LoggingLevel +``` + +### Supported Levels + +| Value | Description | +|------|------------| +| 0 | Standard logging | +| 1 | Verbose logging | +| 2 | Debug logging | + +Logging level can be changed dynamically at runtime. + +--- + +## File Operations Flow + +Each read follows this sequence: + +1. `OpenFile` +2. `ReadFile` +3. Parse and store data +4. `CloseFile` +5. Return tag values + +--- + +## Limitations + +- Maximum file size: **200 KB** +- Only supports ASCII-compatible text +- Does not support UTF-8 multibyte characters +- CSV parsing assumes: + - Comma-separated values + - No quoted or escaped delimiters + +--- + +## Known Considerations + +- File reads occur on every client request +- Frequent polling may impact performance depending on file size +- File access conflicts may occur if another process is writing to the file + +--- + +## Future Enhancements + +- Optional caching to reduce repeated file reads +- UTF-8 support +- Configurable delimiters +- Improved CSV parsing (quoted fields, edge cases) + +--- + +## Summary + +This profile provides a simple polling-based approach for reading file data and exposing it as tags. + +It is well suited for: + +- Static or periodically updated files +- Environments where event-based file monitoring is not required +- Lightweight CSV ingestion scenarios \ No newline at end of file diff --git a/File Access/FileReader_TagImport.csv b/File Access/FileReader_TagImport.csv new file mode 100644 index 0000000..cd7cc0a --- /dev/null +++ b/File Access/FileReader_TagImport.csv @@ -0,0 +1,20 @@ +Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Scaling,Raw Low,Raw High,Scaled Low,Scaled High,Scaled Data Type,Clamp Low,Clamp High,Eng Units,Description,Negate Value +"Data.File","file",String,1,RO,100,,,,,,,,,,"", +"Data.LastLine","lastline",String,1,RO,100,,,,,,,,,,"", +"Data.LastLineField1","lastfield1",String,1,RO,100,,,,,,,,,,"", +"Data.LastLineField2","lastfield2",String,1,RO,100,,,,,,,,,,"", +"Data.LastLineField3","lastfield3",String,1,RO,100,,,,,,,,,,"", +"Data.Line1","line1",String,1,RO,100,,,,,,,,,,"", +"Data.Line1Field1","line1field1",String,1,RO,100,,,,,,,,,,"", +"Data.Line1Field2","line1field2",String,1,RO,100,,,,,,,,,,"", +"Data.Line1Field3","line1field3",String,1,RO,100,,,,,,,,,,"", +"Data.Line2","line2",String,1,RO,100,,,,,,,,,,"", +"Data.Line2Field1","line2field1",String,1,RO,100,,,,,,,,,,"", +"Data.Line2Field2","line2field2",String,1,RO,100,,,,,,,,,,"", +"Data.Line2Field3","line2field3",String,1,RO,100,,,,,,,,,,"", +"Data.Line3","line3",String,1,RO,100,,,,,,,,,,"", +"Data.Line3Field1","line3field1",String,1,RO,100,,,,,,,,,,"", +"Data.Line3Field2","line3field2",String,1,RO,100,,,,,,,,,,"", +"Data.Line3Field3","line3field3",String,1,RO,100,,,,,,,,,,"", +"Data.LineCount","linecount",String,1,RO,100,,,,,,,,,,"", +"Internal.LoggingLevel","LoggingLevel",Word,1,R/W,100,,,,,,,,,,"", diff --git a/File Access/README.md b/File Access/README.md new file mode 100644 index 0000000..e69de29 From a12ad1864ebe1fb1fbd4d1dcb5a272afe33ce8d4 Mon Sep 17 00:00:00 2001 From: MRoyPTC <66799739+MRoyPTC@users.noreply.github.com> Date: Mon, 18 May 2026 10:53:08 -0400 Subject: [PATCH 3/4] Add initial README with available file profiles --- File Access/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/File Access/README.md b/File Access/README.md index e69de29..a2ee15f 100644 --- a/File Access/README.md +++ b/File Access/README.md @@ -0,0 +1,6 @@ +# UDD File Profiles + +## Available Profiles + +- [Directory Watch File Reader](FileReader_DirectoryWatch.md) +- [File Poll Reader](FileReader_FilePoll.md) From 5b99f190e96089970d98dc60af461899e16c5101 Mon Sep 17 00:00:00 2001 From: MRoyPTC <66799739+MRoyPTC@users.noreply.github.com> Date: Mon, 18 May 2026 10:54:01 -0400 Subject: [PATCH 4/4] Update FileReader Poll Profile documentation --- File Access/FileReader_FilePoll.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/File Access/FileReader_FilePoll.md b/File Access/FileReader_FilePoll.md index d1e9388..aea27a6 100644 --- a/File Access/FileReader_FilePoll.md +++ b/File Access/FileReader_FilePoll.md @@ -1,4 +1,4 @@ -# FileReader Poll Profile +# FileReader File Poll Profile Script File: FileReader_FilePoll.js @@ -164,4 +164,4 @@ It is well suited for: - Static or periodically updated files - Environments where event-based file monitoring is not required -- Lightweight CSV ingestion scenarios \ No newline at end of file +- Lightweight CSV ingestion scenarios