diff --git a/EtherNetIP UCMM/README.md b/EtherNetIP UCMM/README.md new file mode 100644 index 0000000..79d735e --- /dev/null +++ b/EtherNetIP UCMM/README.md @@ -0,0 +1,219 @@ +# EtherNet/IP UCMM Profile + +## Overview + +This Universal Device Driver (UDD) profile implements an EtherNet/IP (CIP) client using **UCMM (Unconnected Message Manager)** messaging. It enables communication with EtherNet/IP devices to: + +- Read CIP object attributes +- Access device identity information +- Read tag values using Symbol Object services +- Route messages to downstream devices (hardcoded) + +This profile establishes a session with the target device and exchanges CIP messages using `SendRRData`. + +--- + +## Features + +- EtherNet/IP session management (RegisterSession / UnregisterSession) +- UCMM messaging via `SendRRData` +- CIP Object Class abstraction +- Identity Object (Class 0x01) support +- Symbol Object (Class 0x6B) support (tag reads) +- Basic message routing support (hardcoded path) +- Request/response correlation using context tracking +- Multi-part response handling + +--- + +## Connection Behavior + +The communication sequence follows: + +1. RegisterSession +2. Store Session ID +3. Build and send `SendRRData` (UCMM message) +4. Process response and update tag values +5. Handle retries for fragmented responses + +--- + +## Supported Tag Address Formats + +### 1. CIP Object Addressing + +Format: + +``` +CLASS , INSTANCE , [ATTRIBUTE ], SERVICE +``` + +Examples: + +``` +CLASS 1, INSTANCE 1, ATTRIBUTE 1, SERVICE 14 +CLASS 1, INSTANCE 1, SERVICE 14 +``` + +Notes: +- ATTRIBUTE is optional depending on service +- Values are numeric (decimal) +- Keywords are case-insensitive + +--- + +### 2. Symbol Object (Tag-Based Read) + +Format: + +``` +TAG MyTagName +TAG MyStruct.Field +``` + +Examples: + +``` +TAG MotorSpeed +TAG Pump.Status +``` + +Notes: +- Uses Symbol Object (Class 0x6B) +- Internally converted to CIP symbolic segments +- Supports nested fields via dot notation + +--- + +## Supported CIP Objects + +### Identity Object (Class 0x01) + +Supported service: +- `0x0E` Get_Attribute_Single + +Supported attributes: + +| ID | Name | Type | +|----|-----------------|--------| +| 1 | Vendor ID | Word | +| 2 | Device Type | Word | +| 3 | Product Code | Word | +| 4 | Revision | String | +| 5 | Status | Word | +| 6 | Serial Number | String | +| 7 | Product Name | String | + +--- + +### Symbol Object (Class 0x6B) + +Supported services: +- `0x4C` Read Tag +- `0x52` Read Tag Fragment (partial support) + +Notes: +- Primarily used for reading controller tag values +- Supports simple scalar reads +- Partial datatype decoding implemented + +--- + +## Routing Configuration + +Message routing is currently **hardcoded** in the script: + +``` +Backplane → Slot 1 +``` + +Notes: +- Must be manually modified in script if needed +- No dynamic routing configuration available + +--- + +## Data Handling + +### Request Flow + +- Tag address parsed into CIP parameters +- Mapped to corresponding CIP object +- CIP request constructed +- Wrapped in EtherNet/IP encapsulation (SendRRData) + +### Response Flow + +- Response matched using context ID +- Routed to appropriate CIP object +- Data decoded and stored in tag + +--- + +## Logging Control + +Logging levels: + +| Level | Description | +|------|------------| +| 0 | Verbose logging | +| 1 | Standard logging | +| 2 | Request logging | +| 3 | Response logging | + +Controlled by: + +``` +LOGGING_LEVEL +``` + +--- + +## Limitations + +- Routing path is hardcoded (no runtime configuration) +- Limited CIP object coverage +- Partial datatype support for Symbol Object +- No support for multi-tag batch optimization +- UTF-8 / multibyte strings not supported +- Write services not implemented +- Fragmented reads only partially handled + +--- + +## Known Considerations + +- Session must be established before data exchange +- Invalid session triggers automatic re-registration +- Tag validation enforces strict address format +- Responses must match request context or will be ignored +- Some services may require multiple request cycles + +--- + +## Future Enhancements + +- Dynamic routing configuration +- Additional CIP object support +- Full datatype decoding +- Write service support +- Improved fragmented read handling +- UTF-8 support +- Batch request optimization + +--- + +## Summary + +This profile provides a flexible framework for EtherNet/IP UCMM communications, enabling: + +- Direct CIP object access +- Tag-based symbolic reads +- Modular extension of CIP object handling + +It is well-suited for: + +- Diagnostic access to EtherNet/IP devices +- Testing and validation scenarios +- Custom CIP integrations +- Lightweight industrial connectivity solutions diff --git a/EtherNetIP UCMM/UCMM_client_v5.js b/EtherNetIP UCMM/UCMM_client_v5.js new file mode 100644 index 0000000..c284741 --- /dev/null +++ b/EtherNetIP UCMM/UCMM_client_v5.js @@ -0,0 +1,1432 @@ +/***************************************************************************** + * + * This file is copyright (c) 2024 PTC Inc. + * All rights reserved. + * + * Name: EtherNet/IP-UCMM-profile + * Description: A UCMM communication client profile which can connect with Ethernet/IP device + * Version: 0.1.0 + * Revision history: + * v3 CIP object class support + * In this version, cip object is described by class. The CIP request and response of a CIP object are + * encapsulated into the CIP class. + * + * + * v4 Message Routing support + * CIP message could be routed to the downstream devices. + * The Route info is hardcode in the javascript as there is no way to pass them in currently. + * The user of this script has to manually edit it. + * + * Symbol Object support + * User could read the tag values via symbol read method. + * + * v5 issue fix + * Tags passed in OnData may not match with the one in the response message (data) + * + * +******************************************************************************/ +/** + * @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. + * @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". + */ + + /** + * @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. + */ + +/* Parameters defined for CIP */ +// begin +/** + * @typedef {object} CIPParam + * @property {BOOL} cipParam.validFormat + * @property {Number} cipParam.classCode + * @property {Number} cipParam.instanceID + * @property {Number} cipParam.attributeID + * @property {Number} cipParam.serviceCode + * @property {Number[]} cipParam.symbolSegment + */ +/** + * @typedef {object} CIPRequestCacheElement + * @property {Number} element.key - element key. + * @property {string} element.name - element name. + * @property {DataType} element.datatype - Kepserver data type. + * @property {CIPParam} element.cipParam - element value. + */ + + + +var g_cipObjects = [] +var g_eipRequestCache = [] +var g_context2 +// end + +/** Global variable for driver version */ +const VERSION = "2.0"; + +/** Global variable for driver mode */ +const MODE = "Client" + +/** Status types */ +const ACTIONRECEIVE = "Receive" +const ACTIONCOMPLETE = "Complete" +const ACTIONFAILURE = "Fail" + +/** Current ID, increments when a new bulkId is assigned to a new topic**/ +var CURR_ID = 0; + +/* Parameters defined for CIP */ +// begin +/** EtherNet/IP Connection State **/ +let ListService_status = false; +let RegisterSession_status = false; +let SendRRData_Complete = false; +let UnregisterSession_status = false; + +/** Ethernet/IP session number **/ +let EIP_Session_ID = [0x00, 0x00, 0x00, 0x00]; +// end + +/** Global variables to use for logging level */ +const STD_LOGGING = 1; +const VERBOSE_LOGGING = 0; +const STD_LOGGING_REQUEST = 2; +const STD_LOGGING_RESPONSE = 3; + +// Avoid logging verbose protocol messages unless needed for debugging +// To use verbose logging, set this logging level to VERBOSE_LOGGING +const LOGGING_LEVEL = STD_LOGGING_RESPONSE; + +/** Captures the global log function so that it can be wrapped **/ +let originalLogFunction = log; +log = function (msg, level = LOGGING_LEVEL) { + // Always log the STD_LOGGING even if logging VERBOSE_LOGGING + if (level === LOGGING_LEVEL || level === STD_LOGGING) { + originalLogFunction(msg); + } +} + +/** + * Retrieve driver metadata. + * + * @return {OnProfileLoadResult} - Driver metadata. + */ +function onProfileLoad() { + /* Initialize our internal global cache to store topic PUBLISH responses */ + initializeCache(); + g_cipObjects = [ + {classcode: 0x01, cipobject: new IdentityObject(1)}, + {classcode: 0x6B, cipobject: new SymbolObject(0)}, + + ] + g_context2 = 0x00000000; + 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) { + // To do: + // the tag address in this profile is class xx, instance xx, [attribute xx], service xx + // onValidateTag will verfiy the tag address by + // a) verify the tag address if has "class" "instance" and "service" code. attribute is optional + // the key word class instance service attribute is none case sensitive + // b) get the cip object instance from g_cipobjects, verify if the address class instance attribute service is supported. + // + // + // assign bulkId to the tag + info.tag.valid = false; + let cipParam = GetCIPParamFromTagAddress(info.tag.address.toUpperCase()); + + if (cipParam.validFormat != true){ + log(`onValidateTag: invalid tag address format ${info.tag.address}`, VERBOSE_LOGGING); + return info.tag; + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + let cipobject = GetCIPObject(cipParam.classCode); + if (!cipobject){ + log(`onValidateTag: Unrecognized tag address ${info.tag.address}`, VERBOSE_LOGGING); + return info.tag; + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + let vaildresult = cipobject.OnValidateCIPAddress(info); + + if (!info.tag.valid) + { + log(`onValidateTag: unsupported CIP address ${info.tag.address} in this profile`, VERBOSE_LOGGING); + } + return info.tag; +} +// TODO: +// ListIdentity ListService UnRegisterSession +// +function ListService() { + ListService_status = false; + let listService_data = []; + return listService_data; +} +function OnListService() { + ListService_status = true; + return; +} + +function RegisterSession() { + let RegisterSession_data = []; + RegisterSession_data = [ + 0x65, 0x00, // EIP_CMD_REGISTER_SESSION + 0x04, 0x00, // Length of the message + 0x00, 0x00, 0x00, 0x00, // Session handle + 0x00, 0x00, 0x00, 0x00, // Status + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Sender Context + 0x00, 0x00, 0x00, 0x00, // Options + 0x01, 0x00, // Protocol version + 0x00, 0x00 // Option flags + ] + return RegisterSession_data; +} +/** + * Handle the response of the RegisterSession request. + * + * @param {Data} data - The incoming data. + * + */ +function OnRegisterSession(data) { + let command = data[1]<<8|data[0]; + let status = (data[11]<<24|data[10]<<16|data[9]<<8||data[8])&0x00000000FFFFFFFFFF; + + if (command == 0x65 && status == 0x00000000) { + + EIP_Session_ID[0] = data[4]; + EIP_Session_ID[1] = data[5]; + EIP_Session_ID[2] = data[6]; + EIP_Session_ID[3] = data[7]; + + log(`OnRegisterSession: RegisterSession Success to Downstream Device, Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + RegisterSession_status = true; + } + else if (command == 0x65 && status == 1 && (EIP_Session_ID.toString()!=[0,0,0,0].toString())) { + log(`OnRegisterSession: RegisterSession Invalid Request to Downstream Device as already had one Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + } + else { + log(`OnRegisterSession: RegisterSession Failed to Downstream Device, Command: ${command.toString(16)}, Status: ${status.toString(16)}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + } + return RegisterSession_status +} + +function UnregisterSession() { + UnregisterSession_status = true; + EIP_Session_ID = [0,0, 0, 0]; + return UnRegisterSession_data; +} +function OnUnregisterSession() { + ListService_status = false; + RegisterSession_status = false; + UnregisterSession_status = false; + return; +} +/** + * Build the SendRRData request, with tag information + * + * @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 {Data}} +*/ +function SendRRData(info) { + let tag_address = info.tags[0].address; + g_context2 ++; + let cipRequestParam = GetCIPParamFromTagAddress(tag_address.toUpperCase()); + log(`SendRRData: GetCIPParamFromTagAddress for tag ${tag_address}, class ${cipRequestParam.classCode}`, STD_LOGGING_REQUEST); + + let eipEncapsulationData = [ + /////////////////////////////////////////////////////////////////////////// + //Command + 0x6f, 0x00, + // Length for command specfic data block + // index in array, 2 + 0x16, 0x00, + //Session handle + EIP_Session_ID [0], EIP_Session_ID [1], EIP_Session_ID [2], EIP_Session_ID [3], + //Status + 0x00, 0x00, 0x00, 0x00, + //set sender context1 + cipRequestParam.classCode, cipRequestParam.instanceID, cipRequestParam.attributeID,cipRequestParam.serviceCode, + //set sender context2 + g_context2&0x000000FF, (g_context2&0x0000FF00)>>8, (g_context2&0x00FF0000)>>16, (g_context2&0xFF000000)>>24, + //Options + 0x00, 0x00, 0x00, 0x00, + + //command specfic data block + //{{{ + //Interface handle + 0x00, 0x00, 0x00, 0x00, + //Timeout + 0x00, 0x00, + //Encapsulated Packet + 0x02, 0x00, + ////Address Type ID, 0 for UCMM + 0x00, 0x00, + ////Address length, 0 for UCMM + 0x00, 0x00, + ////Data Type ID, B2 for UCMM + 0xb2, 0x00, + + //////////////////////////////////////////////////////////////////////////////// + + //CIP Package length + // Index in array, 38 + 0x00, 0x00, + + //CIP Package Begin + //Unconnected Send Command, connection manager + 0x52, + 0x02, + 0x20, 0x06, + 0x24, 0x01, + //Priority timout ticks + 0x07, 0x80, + + //Embeded Message Size + // Index in array, 48 + 0x00, 0x00, + + //Multiple Service Request, message router + 0x0A, + 0x02, + 0x20, 0x02, + 0x24, 0x01, + + //CIP message command count + // offset position 0 + 0x01, 0x00, + + //offset of 1st CIP msg cmd. + 0x04, 0x00 + + // offset position 4 + // 1st CIP msg cmd + + + + + + ]; + + let cipCmd= []; + let cipobject = GetCIPObject(cipRequestParam.classCode); + if (!cipobject){ + log(`SendRRData: unsupported cip class ${cipRequestParam.classCode}`, STD_LOGGING_REQUEST); + return cipCmd; + } + + cipCmd = cipobject.OnCIPServiceRequest(cipRequestParam,info); + + + cipCmd.forEach(element => { + eipEncapsulationData.push(element); + }); +//////////////////////////////////////////////////////////////////////////////// +// add route path size + eipEncapsulationData.push(0x01); + eipEncapsulationData.push(0x00); +// add rout info, backplane, slot 1 + eipEncapsulationData.push(0x01); + eipEncapsulationData.push(0x00); +// CIP Package End +//}}} + eipEncapsulationData[48] = cipCmd.length + 10; + eipEncapsulationData[38] = 4 + cipCmd.length + 20; + eipEncapsulationData[2] = 4 + cipCmd.length + 36; + + g_eipRequestCache.push({key:g_context2, name:tag_address, cipParam:cipRequestParam}); + log(`SendRRData: push g_eipRequestCache, context1 ${cipRequestParam.classCode.toString(16)} ${cipRequestParam.instanceID.toString(16)} ${cipRequestParam.attributeID.toString(16)} ${cipRequestParam.serviceCode.toString(16)} context2 ${g_context2} for tag ${tag_address} `, STD_LOGGING_REQUEST); + + + log(`SendRRData: Build SendRRData Request context1 ${cipRequestParam.classCode.toString(16)} ${cipRequestParam.instanceID.toString(16)} ${cipRequestParam.attributeID.toString(16)} ${cipRequestParam.serviceCode.toString(16)} context2 ${g_context2} for tag ${tag_address} to Downstream Device: ${Bytes2Str(eipEncapsulationData)}`, STD_LOGGING_REQUEST); + return eipEncapsulationData; +} +/** + * 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 OnSendRRData(info) { + log(`OnSendRRData: Received response from Downstream Device: ${Bytes2Str(info.data)}`, VERBOSE_LOGGING); + let data = info.data; + let command = data[1]<<8|data[0]; + let cipParam = { } + let classCode = data[12]; + let instanceID = data[13]; + let attributeID = data[14]; + let serviceCode = data[15]; + + + let context2= (data[19]<<24)|(data[18]<<16)|(data[17]<<8)|data[16]; + let sessionID =[data[4],data[5], data[6], data[7]]; + let status = (data[11]<<24)|(data[10]<<16)|(data[9]<<8)||data[8]; + log(`OnSendRRData: command ${command.toString(16)}, sessionID ${Bytes2Str(sessionID)}, status ${status.toString(16)}`, VERBOSE_LOGGING); + + let cipNextRequestElement = {}; + let result = {}; + + g_eipRequestCache.forEach(cipRequestElement => { + if(cipRequestElement.key == context2){ + cipNextRequestElement = cipRequestElement; + cipParam.validFormat = cipRequestElement.cipParam.validFormat; + cipParam.classCode = cipRequestElement.cipParam.classCode; + cipParam.instanceID = cipRequestElement.cipParam.instanceID; + cipParam.attributeID = cipRequestElement.cipParam.attributeID; + cipParam.serviceCode = cipRequestElement.cipParam.serviceCode; + cipParam.symbolSegment = cipRequestElement.cipParam.symbolSegment; + log(`OnSendRRData: find context2 ${context2} in g_eipRequestCache, result is cipParam.classCode ${cipParam.classCode}, cipParam.instanceID ${cipParam.instanceID}, cipParam.attributeID ${cipParam.attributeID}, cipParam.serviceCode ${cipParam.serviceCode}`, VERBOSE_LOGGING); + + } + }); + + if (cipParam.classCode != classCode || + cipParam.serviceCode != serviceCode || + cipParam.instanceID != instanceID || + cipParam.attributeID != attributeID + ){ + log(`OnSendRRData: SendRRData failed due to mismatched context2 ${context2} with cip requests: class ${classCode}, instance ${instanceID}, atti ${attributeID}, service ${serviceCode}}`, STD_LOGGING_RESPONSE); + return { action: ACTIONCOMPLETE }; + } + //log(`OnSendRRData: find context2 in g_eipRequestCache, result is cipParam.classCode ${cipParam.classCode}, cipParam.instanceID ${cipParam.instanceID}, cipParam.attributeID ${cipParam.attributeID}, cipParam.symbolSegment ${Bytes2Str(cipParam.symbolSegment)}`, VERBOSE_LOGGING); + log(`OnSendRRData: find context2 ${context2} in g_eipRequestCache, result is cipParam.classCode ${cipParam.classCode}, cipParam.instanceID ${cipParam.instanceID}, cipParam.attributeID ${cipParam.attributeID}`, VERBOSE_LOGGING); + g_eipRequestCache.forEach(function(item, index, arr) { + if(item.key == context2) { + arr.splice(index, 1); + } + }); + + + + + + if (command == 0x6f && status == 0x00000000 && info.tags && (typeof (info.tags) != "undefined")) + { + + if(sessionID.toString() == EIP_Session_ID.toString()) + { + + + let cipobject = GetCIPObject(classCode); + let result = cipobject.OnCIPServiceResponse(cipParam, info); + + if(result.action == ACTIONRECEIVE){ + log(`OnSendRRData: Need to extra SendRRData to get the whole response for class ${cipParam.classCode} instance ${cipParam.instanceID} attribute ${cipParam.attributeID} service ${cipParam.serviceCode}`, STD_LOGGING_RESPONSE|STD_LOGGING_REQUEST|STD_LOGGING|VERBOSE_LOGGING); + let tags= [{MessageType:"READ", address: cipNextRequestElement.name }] + let extraCIPRequest = SendRRData({tags:tags}); + result = {action:ACTIONRECEIVE, data:extraCIPRequest}; + log(`OnSendRRData: sending extra SendRRData to get the whole response for class ${cipParam.classCode} instance ${cipParam.instanceID} attribute ${cipParam.attributeID} service ${cipParam.serviceCode} data ${Bytes2Str(extraCIPRequest)}`, STD_LOGGING_RESPONSE|STD_LOGGING_REQUEST|STD_LOGGING|VERBOSE_LOGGING); + + } + /* + switch (context1) + { + case 0x01010100: + { + + } + break; + case 0x0101010E: + { + let servicecode = data[40]; + let statuscode = data[43]<<8|data[42]; + let vendorID = data[45]<<8|data[44]; + + let writeResult; + + g_map.forEach(element => { + if (element.key == 0x0101010E) { + // could Write to Cache here, if it is needed. + //element.value = vendorID; + //writeResult = writeToCache(element.key.toString(16), vendorID.toString() ); + info.tags[0].value = vendorID; + } + }); + // if Write to Cache , then return Action complete, no need to update tags + //result = { action: ACTIONCOMPLETE}; + result = { action: ACTIONCOMPLETE, tags: info.tags}; + log(`OnSendRRData: Success to process the response for Tag: ${element.name}, Session ID: ${Bytes2Str(EIP_Session_ID)}, write result ${writeResult}`, VERBOSE_LOGGING); + } + break; + default: + { + result = { action: ACTIONFAILURE}; + log(`OnSendRRData: Failed to process the response for Context1: ${context1.toString(16)}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + } + break; + }*/ + return result; + } + else { + log(`OnSendRRData: Failed due to incorrect sessionID ${sessionID.toString(16)}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } + } + else if (command == 0x6f && status == 0x00000000 && !info.tags) { + log(`OnSendRRData: SendRRData failed due to no tags to process, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } + else if (command == 0x6f && status == 0x00000000 && (typeof (info.tags) == "undefined")) { + log(`OnSendRRData: SendRRData failed due to tags are "undefined", Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } + else if (command == 0x6f && status != 0x00000000 ) { + log(`OnSendRRData: SendRRData failed, status: ${status.toString(16)}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + if(status === 0x64){ + log(`OnSendRRData: SendRRData failed because Session ID: ${Bytes2Str(EIP_Session_ID)} invalid`, STD_LOGGING_RESPONSE); + log(`OnSendRRData: Re-register Session`, STD_LOGGING_RESPONSE); + EIP_Session_ID =[0, 0, 0, 0]; + RegisterSession_status = false; + return {action: ACTIONCOMPLETE }; + + } + log(`OnSendRRData: SendRRData failed due to no error handling for status: ${status.toString(16)}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } + else { + log(`OnSendRRData: SendRRData failed status reported from Downstream Device, Status: ${status.toString(16)}, tags count: ${info.tags.length}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } +} + + +/** + * 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) { + let data = []; + log(`onTagsRequest: Processing Request for tag: ${info.tags[0].address}`, STD_LOGGING_REQUEST); + // Ensure EtherNet/IP connection is established + //if (!ListService_status) { + // // Build ListService request + // data = ListService (); + // log(`onTagsRequest: Sending ListService Request to Downstream Device: ${data}`, VERBOSE_LOGGING); + // return { action: ACTIONRECEIVE, data: data }; + //} + if (/*ListService_status && */ + !RegisterSession_status) + { + // Build RegisterSession request + data = RegisterSession (); + log(`onTagsRequest: Sending RegisterSession Request to Downstream Device: ${Bytes2Str(data)}`, STD_LOGGING_REQUEST); + return { action: ACTIONRECEIVE, data: data }; + } + // Build EtherNet/IP UCMM request for normal tags + // + // if RegisterSession_status is false, + // means the EtherNet/IP connection is not established + // if it is true, then the EtherNet/IP connection should be established + // then we need to chech the session id, + // if the session id is 0, then we can send the request to get the tag values + if(/*ListService_status && */ + RegisterSession_status ) + { + if(EIP_Session_ID.toString() != [0, 0, 0, 0].toString()) { + + let cipRequest = SendRRData (info); + if(!cipRequest){ + log(`onTagsRequest: failed to get RR data!`, STD_LOGGING_REQUEST); + return { action: ACTIONFAILURE }; + } + if (!SendRRData_Complete){ + log(`onTagsRequest: WARNING, Issue SendRRData Request before previous one responsed from Downstream Device, tag: ${info.tags[0].address}`, STD_LOGGING_REQUEST); + } + log(`onTagsRequest: Sending SendRRData Request for tag ${info.tags[0].address} to Downstream Device: ${Bytes2Str(cipRequest)}`, STD_LOGGING_REQUEST); + SendRRData_Complete = true; + + let result = {action: ACTIONRECEIVE, data: cipRequest}; + // The value saved in Cache, then we can retrieve the value here. + // Need to update value and send next request at the same time + // we should return Action complete, tags, also the request + // + // if just not read from cache but from response, then just return + // with action receive, and next reqest. + //return { action: ACTIONCOMPLETE, tags: info.tags, data: data }; + + /* + let tag_address = info.tags[0].address; + + + let cipRequestParam = GetCIPParamFromTagAddress(tag_address.toUpperCase()); + let cipobject = GetCIPObject(cipRequestParam.classCode); + let retGetValue = cipobject.GetAttributeValues(cipRequestParam, info); + if (retGetValue){ + result = { action: ACTIONRECEIVE, tags: info.tags, data: cipRequest }; + } + else{ + result = { action: ACTIONRECEIVE, data: cipRequest }; + } + */ + + return result; + + } + else { + log(`onTagsRequest: invalid EIP_Session_ID, Unrecognized status!`, STD_LOGGING_REQUEST); + return { action: ACTIONFAILURE }; + } + } + +} + +/** + * 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; + + let command = inboundData[1]<<8|inboundData[0]; + let status = inboundData[11]<<24|inboundData[10]<<16|inboundData[9]<<8||inboundData[8]; + + log(`onData: Received response from Downsteam Device: command ${command.toString(16)}, status ${status.toString(16)}`, VERBOSE_LOGGING); + + switch (command) { + case 0x65: + { + // RegisterSession response + let result = OnRegisterSession(inboundData); + if (result) { + if(info.tags) + info.tags[0].value = 0; + return { action: ACTIONCOMPLETE, tags: info.tags }; + } + else { + log(`onData: OnRegisterSession Failed to get the session ID, Command: ${command.toString(16)}, Status: ${status.toString(16)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } + } + case 0x6f: + { + // SendRRData response + let result = OnSendRRData(info); + + ////////////////////////////////////// + /* + let tag_address = info.tags[0].address; + + + let cipRequestParam = GetCIPParamFromTagAddress(tag_address.toUpperCase()); + let cipobject = GetCIPObject(cipRequestParam.classCode); + let retGetValue = cipobject.GetAttributeValues(cipRequestParam, info); + if (retGetValue){ + result = { action: ACTIONCOMPLETE, tags: info.tags }; + } + else{ + result = { action: ACTIONCOMPLETE }; + }*/ + ///////////////////////////////////// + log(`onData: OnSendRRData Done, result: ${result.action}`, VERBOSE_LOGGING); + SendRRData_Complete = false; + + return result; + } + default: + log(`onData: Unrecognized Ethernet/IP command! Command: ${command.toString(16)}, Status: ${status.toString(16)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE }; + } +} + +/* ******************************* + * Helper functions + * *******************************/ + +/* ******************************* + * Nibble manipulation helpers + * *******************************/ +function hiNibble (byte) { + return (byte & 0xF0) >> 4 +} + +function loNibble (byte) { + return (byte & 0xF) +} + +function byteFromNibble (hi, lo) { + return hi << 4 | lo +} + +/* ******************************* + * Word manipulation helpers + * *******************************/ +function hiByte (word) { + return (word & 0xFF00) >> 8; +} + +function loByte (word) { + return (word & 0xFF); +} + +function wordFromBytes (hi, lo) { + return hi << 8 | lo; +} + +/** + * Note: + * This function does not support UTF-8 encoded multibyte characters! + * It must be extended to work with topics, payloads, and client names + * that include such extended unicode characters. + */ +function stringToByteArray (str) { + var arr = []; + for (var i = 0; i < str.length; i++) { + arr.push(str.charCodeAt(i)); + } + return arr; +} + +/** + * Note: + * This function does not handle UTF-8 encoded multibyte characters! + * It must be extended to work with topics, payloads, and client names + * that include such extended unicode characters. + */ +function byteArrayToString(data) { + return String.fromCharCode.apply(null, data); +} + +function Bytes2Str(arr) { + var str = ""; + for (var i = 0; i < arr.length; i++) { + var tmp; + var num=arr[i]; + if (num < 0) { + tmp =(255+num+1).toString(16); + } else { + tmp = num.toString(16); + } + if (tmp.length == 1) { + tmp = "0" + tmp; + } + str += tmp; + str += " "; + } + return str; + } + +function GetCIPParamFromTagAddress(tagAddress){ + let validFormat = false; + let classCode = 0; + let instanceID = 0; + let attributeID = 0; + let serviceCode = 0; + let symbolSegment = []; + let tag_address = tagAddress; + + //class 1, instance 1, attribute 1, service 14 + if(tag_address.includes("TAG")){ + if (tag_address.includes("CLASS") || tag_address.includes("INSTANCE") ||tag_address.includes("SERVICE")){ + + log(`GetCIPParamFromTagAddress for address ${tagAddress}, invalid format`, VERBOSE_LOGGING); + return {validFormat:validFormat, classCode:classCode, instanceID:instanceID, attributeID:attributeID, serviceCode: serviceCode, symbolSegment:symbolSegment}; + } + + if(tag_address.includes("TAG ")&& (tag_address.indexOf("TAG ")== 0)){ + classCode = 0x6B; + instanceID = 0; + attributeID = 0; + serviceCode = 0x4C; + symbolSegment = []; + let TagNameOffset = tag_address.indexOf("TAG ") + "TAG ".length; + + let TagName = String (tag_address.slice(TagNameOffset)); + let TempSymbol = TagName; + while(TempSymbol.length){ + if(TempSymbol.includes(".")&&TempSymbol.indexOf(".")!=0){ + + let subTempSymbol = TempSymbol.slice(0, TempSymbol.indexOf('.')); + TempSymbol = TempSymbol.slice(indexOf('.')+1); + + symbolSegment.push(0x91); + symbolSegment.push(subTempSymbol.length); + for (var i = 0; i < subTempSymbol.length; i++) { + symbolSegment.push(subTempSymbol.charCodeAt(i)); + } + if(subTempSymbol.length%2 == 1) + symbolSegment.push(0); + continue; + } + + else if(TempSymbol.includes("[")&&TempSymbol.indexOf("[")){ + let arrayDim = TempSymbol.slice(TempSymbol.indexOf("[")+1, TempSymbol.indexOf("]")); + TempSymbol = TempSymbol.slice(TempSymbol.indexOf("]")+1); + + //TempSymbol.split + } + + else{ + let subTempSymbol = TempSymbol.slice(0); + TempSymbol = TempSymbol.slice(TempSymbol.length+1); + + symbolSegment.push(0x91); + symbolSegment.push(subTempSymbol.length); + for (var i = 0; i < subTempSymbol.length; i++) { + symbolSegment.push(subTempSymbol.charCodeAt(i)); + } + if(subTempSymbol.length%2 == 1) + symbolSegment.push(0); + + continue; + } + } + validFormat = true; + } + else{ + log(`GetCIPParamFromTagAddress for address ${tagAddress}, invalid format`, VERBOSE_LOGGING); + return {validFormat:validFormat, classCode:classCode, instanceID:instanceID, attributeID:attributeID, serviceCode: serviceCode, symbolSegment:(symbolSegment)}; + } + } + else{ + if (tag_address.includes("CLASS ")&& (tag_address.indexOf("CLASS ")== 0) && tag_address.includes(", INSTANCE ")&& tag_address.includes(", SERVICE ")){ + let classCodeOffset = tag_address.indexOf("CLASS ") + "CLASS ".length; + let instanceOffset = tag_address.indexOf(", INSTANCE ") + ", INSTANCE ".length; + let serviceOffset = tag_address.indexOf(", SERVICE ") + ", SERVICE ".length; + let attibuteOffset = 0; + //log(`GetCIPParamFromTagAddress for address ${tagAddress}, class offset ${classCodeOffset}, instance offset ${instanceOffset}, service offset ${serviceOffset}`, VERBOSE_LOGGING); + + if(tag_address.includes(", ATTRIBUTE ")){ + + attibuteOffset = tag_address.indexOf(", ATTRIBUTE ") + ", ATTRIBUTE ".length; + //log(`GetCIPParamFromTagAddress for address ${tagAddress}, includes attribute, attribute offset ${attibuteOffset}`, VERBOSE_LOGGING); + if ((classCodeOffset < instanceOffset) && (instanceOffset < attibuteOffset)&& (attibuteOffset < serviceOffset)){ + validFormat = true; + classCode = Number(tag_address.slice(classCodeOffset, instanceOffset - ", INSTANCE ".length)); + instanceID = Number(tag_address.slice(instanceOffset, attibuteOffset - ", ATTRIBUTE ".length)); + attributeID = Number(tag_address.slice(attibuteOffset, serviceOffset - ", SERVICE ".length)); + serviceCode = Number(tag_address.slice(serviceOffset)); + } + } + else{ + //log(`GetCIPParamFromTagAddress for address ${tagAddress}, doesn't include attribute`, VERBOSE_LOGGING); + if (classCodeOffset < instanceOffset && instanceOffset < serviceOffset){ + validFormat = true; + classCode = Number(tag_address.slice(classCodeOffset, instanceOffset - ", INSTANCE ".length)); + instanceID = Number(tag_address.slice(instanceOffset, serviceOffset - ", SERVICE ".length)); + serviceCode = Number(tag_address.slice(serviceOffset)); + } + + } + //log(`GetCIPParamFromTagAddress for address ${tagAddress}, class ${classCode}, instance ${instanceID}, attribute ${attributeID}, service ${serviceCode}`, VERBOSE_LOGGING); + } + } + log(`GetCIPParamFromTagAddress for address ${tagAddress}, class ${classCode}, instance ${instanceID}, attribute ${attributeID}, service ${serviceCode}, symbol ${Bytes2Str(symbolSegment)}`, VERBOSE_LOGGING); + return {validFormat:validFormat, classCode:classCode, instanceID:instanceID, attributeID:attributeID, serviceCode: serviceCode, symbolSegment:(symbolSegment)}; +} +function GetCIPObject(classcode){ + let cipobject; + //log(`GetCIPObject for class ${classcode}`, VERBOSE_LOGGING); + g_cipObjects.forEach(element =>{ + if (element.classcode == classcode) { + cipobject = element.cipobject; + } + }); + //log(`GetCIPObject for class ${classcode}, cipobject ${cipobject}`, VERBOSE_LOGGING); + return cipobject; +} +/* ******************************* + * class CIPObject + * *******************************/ +class CIPObject { + constructor() { + this.classCode = 0x00; + this.instanceID = 0x00; + + this.services = []; + + + this.attributes= []; + } + /** + * Validate CIP 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. + */ + OnValidateCIPAddress(info) { + // To do: + // 1. validate tag address and check if it exists in the NL20 + // 2. assign bulkId to the tag + info.tag.valid = false; + + return info.tag; + } + /** + * Build the service request message. + * + * @param {CIPParam} cipParam + * @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 {Data} - The action to take, tags to complete (if any) and/or data to send (if any). + * + **/ + OnCIPServiceRequest(cipParam, info){ + + } + /** + * Processing the incoming data pacakge + * Reterive the attibutes and update the property of the class + * + * @param {CIPParam} cipParam + * @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). + * + **/ + OnCIPServiceResponse(cipParam, info) { + + } + /** + * Get the attribute value from the property of the class + * @param {CIPParam} cipParam + * @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 {Boolean} + * + **/ + GetAttributeValues(){ + + } +} + +class IdentityObject extends CIPObject{ + + constructor(instanceID){ + super(); + this.classCode = 0x01; + this.instanceID = instanceID; + + this.services = [ + {serviceCode: 0x0E, serviceName: "Get_Attribute_Single", serviceResult:0x00} + ]; + + + this.attributes= [ + {attr_Id:1, attr_Name:"vendor ID",attr_Datatype:"Word", attr_ReadOnly: true, attr_Value:0x00}, + {attr_Id:2, attr_Name:"Device Type",attr_Datatype:"Word", attr_ReadOnly: true, attr_Value:0x00}, + {attr_Id:3, attr_Name:"Product Code",attr_Datatype:"Word", attr_ReadOnly: true, attr_Value:0x00}, + {attr_Id:4, attr_Name:"Revision",attr_Datatype:"String", attr_ReadOnly: true, attr_Value:0x00}, + {attr_Id:5, attr_Name:"Status",attr_Datatype:"Word", attr_ReadOnly: true, attr_Value:0x00}, + {attr_Id:6, attr_Name:"Serial Number",attr_Datatype:"String", attr_ReadOnly: true, attr_Value:0x00}, + {attr_Id:7, attr_Name:"Product Name",attr_Datatype:"String", attr_ReadOnly: true, attr_Value:""} + ]; + log(`Identity Object, constructor succeed`, VERBOSE_LOGGING); + } + /** + * Validate CIP 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. + */ + OnValidateCIPAddress(info) { + // To do: + // 1. validate CIP address and verify class instance attribute and service + // 2. assign bulkId to the tag + //constructor + info.tag.valid = false; + + log(`Identity Object, OnValidateCIPAddress, for tag ${info.tag.address}`, VERBOSE_LOGGING); + + let cipParam = GetCIPParamFromTagAddress(info.tag.address.toUpperCase()); + log(`Identity Object, OnValidateCIPAddress, GetCIPParamFromTagAddress validformat ${cipParam.validFormat}, class ${cipParam.classCode}, instance ${cipParam.instanceID}, attribute ${cipParam.attributeID}, service ${cipParam.serviceCode}`, VERBOSE_LOGGING); + if(cipParam.validFormat != true){ + log(`Identity Object, OnValidateCIPAddress, return as invlid format`, VERBOSE_LOGGING); + return info.tag; + } + + if(this.classCode != cipParam.classCode){ + log(`Identity Object, OnValidateCIPAddress, return as class code not match this.classcode ${this.classCode} cipParam.classcode ${cipParam.classCode}`, VERBOSE_LOGGING); + return info.tag; + } + + if(this.instanceID != cipParam.instanceID){ + log(`Identity Object, OnValidateCIPAddress, return as instance code not match this.instanceID ${this.instanceID} cipParam.instanceID ${cipParam.instanceID}`, VERBOSE_LOGGING); + return info.tag; + } + + let service; + this.services.forEach(element =>{ + if(element.serviceCode == cipParam.serviceCode) + { + service = element; + } + }); + if(!service){ + log(`Identity Object, OnValidateCIPAddress, return as service code not supported, cipParam.servicecode ${cipParam.serviceCode}`, VERBOSE_LOGGING); + return info.tag; + } + + if(cipParam.attributeID == 0){ + // To Do: + // for no attribute param, the data type of result could be different + // should return based on document + // for now, temperately return with invalid + } + else + { + let attribute; + this.attributes.forEach(attrelement => { + if(attrelement.attr_Id == cipParam.attributeID){ + attribute = attrelement; + } + }); + if(!attribute){ + log(`Identity Object, OnValidateCIPAddress, return as attribute not supported, cipParam.attributeID ${cipParam.attributeID}`, VERBOSE_LOGGING); + return info.tag; + } + info.tag.dataType = attribute.attr_Datatype; + info.tag.readOnly = attribute.attr_ReadOnly; + info.tag.valid = true; + } + + + + return info.tag; + } + /** + * Get the attribute value from the property of the class + * @param {CIPParam} cipParam + * @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 {Boolean} + **/ + GetAttributeValues(cipParam, info){ + + if(cipParam.classCode!= this.classCode || + cipParam.instanceID != this.instanceID + ){ + return false; + } + + this.attributes.forEach(element => { + if(element.attr_Id == cipParam.attributeID){ + info.tags[0].value = element.attr_Value; + log(`Identity Object, GetAttributeValues, retrive attribute ${cipParam.attributeID}, value ${element.attr_Value}`, VERBOSE_LOGGING); + return true; + } + }); + return false; + + } + /** + * Build the service request message. + * + * @param {CIPParam} cipParam + * @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 {Data} + * + **/ + OnCIPServiceRequest(cipParam, info){ + // Get_Vender_ID, class 01, instance 01, attribute 01, service code 0E + + log(`Identity Object, OnCIPServiceRequest, for class ${cipParam.classCode}, instance ${cipParam.instanceID}, attribute ${cipParam.attributeID}`, STD_LOGGING_REQUEST); + let cipData = []; + switch (cipParam.serviceCode) + { + case 0x0E:{ + cipData =[ + cipParam.serviceCode, + 0x03, + 0x20, cipParam.classCode, + 0x24, cipParam.instanceID, + 0x30, cipParam.attributeID + ]; + break; + } + default: + break; + } + + + return cipData ; + } + /** + * Processing the incoming data pacakge + * Reterive the attibutes and update the property of the class + * + * @param {CIPParam} cipParam + * @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). + * + **/ + OnCIPServiceResponse(cipParam, info){ + let data = info.data; + + info.tags.forEach(element => { + log(`Identity Object, OnCIPServiceResponse: process the response for tag ${element.address}`, VERBOSE_LOGGING); + + }); + + + let cipResponse = []; + + for (let index = 48; index <= data.length; index ++){ + cipResponse.push(data[index]); + } + let servicecode = cipResponse[0]; + let statuscode = cipResponse[3]<<8|cipResponse[2]; + + let attribute; + this.attributes.forEach(element => { + if(element.attr_Id == cipParam.attributeID){ + attribute = element; + } + }); + if(!attribute){ + log(`Identity Object, OnCIPServiceResponse: Failed to process the response for attribute ${cipParam.attributeID}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONFAILURE}; + } + + switch (cipParam.attributeID) + { + case 0x01:{ + let vendorID = cipResponse[5]<<8|cipResponse[4]; + attribute.attr_Value = vendorID; + break; + } + case 0x02:{ + let DeviceType = cipResponse[5]<<8|cipResponse[4]; + attribute.attr_Value = DeviceType; + break; + } + case 0x03:{ + let productCode = cipResponse[5]<<8|cipResponse[4]; + attribute.attr_Value = productCode; + break; + } + case 0x04:{ + let maj = cipResponse[4]; + let min = cipResponse[5]; + attribute.attr_Value = maj.toLocaleString()+"."+min.toLocaleString(); + break; + } + case 0x05:{ + let Status = cipResponse[5]<<8|cipResponse[4]; + attribute.attr_Value = Status; + break; + } + case 0x06:{ + let serialNumber = cipResponse[7].toString(16).toUpperCase()+cipResponse[6].toString(16).toUpperCase()+cipResponse[5].toString(16).toUpperCase()+cipResponse[4].toString(16).toUpperCase(); + attribute.attr_Value = serialNumber; + break; + } + case 0x07:{ + let length = cipResponse[4]; + let productnamebuffer = []; + for(let index = 0; index < length; index++ ){ + productnamebuffer.push(cipResponse[5 + index]); + } + //log(`Identity Object, OnCIPServiceResponse: ProductNameBuffer value ${productnamebuffer}, String value ${productnamebuffer.toLocaleString()}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + let productName = String.fromCharCode(...productnamebuffer); + //log(`Identity Object, OnCIPServiceResponse: Success to process the response for Tag: ${info.tags[0].address}, value ${productName}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + attribute.attr_Value = productName; + break; + } + default: + break; + } + + //6f 00 20 00 75 73 00 40 00 00 00 00 01 01 06 0e 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02 00 00 00 00 00 b2 00 10 00 8a 00 00 00 01 00 04 00 8e 00 00 00 fb 7a 06 00 '. + let cipRequestParam = GetCIPParamFromTagAddress(info.tags[0].address.toUpperCase()); + + let result; + + if(cipRequestParam.classCode == this.classCode && cipRequestParam.instanceID == this.instanceID){ + + this.attributes.forEach(element => { + if(element.attr_Id == cipRequestParam.attributeID){ + attribute = element; + } + }); + info.tags[0].value = attribute.attr_Value; + result = { action: ACTIONCOMPLETE, tags: info.tags}; + + log(`Identity Object, OnCIPServiceResponse: Success to process the response for Tag: ${info.tags[0].address}, value ${info.tags[0].value}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, VERBOSE_LOGGING); + } + else{ + //Save value to attributes, but not update tags here, as the tag from info may not match with the response message. + result = { action: ACTIONCOMPLETE}; + log(`Identity Object, OnCIPServiceResponse: Success to process the response for attribute ${cipParam.attributeID}, value ${info.tags[0].value}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + } + + return result; + } +} +class SymbolObject extends CIPObject{ + constructor(instanceID){ + super(); + this.classCode = 0x6B; + this.instanceID = 0; + + this.services = [ + {serviceCode: 0x4C, serviceName: "Read_Tag", serviceResult:0x00}, + {serviceCode: 0x52, serviceName: "Read_Tag_Fregment", serviceResult:0x00} + ]; + + log(`Symbol Object, constructor succeed`, VERBOSE_LOGGING); + } + /** + * Validate CIP 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. + */ + OnValidateCIPAddress(info) { + // To do: + // 1. validate CIP address and verify class instance attribute and service + // 2. assign bulkId to the tag + //constructor + info.tag.valid = false; + + log(`Symbol Object, OnValidateCIPAddress, for tag ${info.tag.address}`, VERBOSE_LOGGING); + + let cipParam = GetCIPParamFromTagAddress(info.tag.address.toUpperCase()); + log(`Symbol Object, OnValidateCIPAddress, GetCIPParamFromTagAddress validformat ${cipParam.validFormat}, class ${cipParam.classCode}, instance ${cipParam.instanceID}, attribute ${cipParam.attributeID}, service ${cipParam.serviceCode}`, VERBOSE_LOGGING); + if(cipParam.validFormat != true){ + log(`Symbol Object, OnValidateCIPAddress, return as invlid format`, VERBOSE_LOGGING); + return info.tag; + } + + if(this.classCode != cipParam.classCode){ + log(`Symbol Object, OnValidateCIPAddress, return as class code not match this.classcode ${this.classCode} cipParam.classcode ${cipParam.classCode}`, VERBOSE_LOGGING); + return info.tag; + } + + if(this.instanceID != cipParam.instanceID){ + log(`Symbol Object, OnValidateCIPAddress, return as instance code not match this.instanceID ${this.instanceID} cipParam.instanceID ${cipParam.instanceID}`, VERBOSE_LOGGING); + return info.tag; + } + + let service; + this.services.forEach(element =>{ + if(element.serviceCode == cipParam.serviceCode) + { + service = element; + } + }); + if(!service){ + log(`Symbol Object, OnValidateCIPAddress, return as service code not supported, cipParam.servicecode ${cipParam.serviceCode}`, VERBOSE_LOGGING); + return info.tag; + } + + info.tag.dataType = "Byte"; + info.tag.readOnly = true; + info.tag.valid = true; + + + + + return info.tag; + } + /** + * Get the attribute value from the property of the class + * @param {CIPParam} cipParam + * @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 {Boolean} + **/ + GetAttributeValues(cipParam, info){ + return false; + } + /** + * Build the service request message. + * + * @param {CIPParam} cipParam + * @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 {Data} - The action to take, tags to complete (if any) and/or data to send (if any). + * + **/ + OnCIPServiceRequest(cipParam, info){ + // Get_Vender_ID, class 01, instance 01, attribute 01, service code 0E + + log(`Symbol Object, OnCIPServiceRequest, for class ${cipParam.classCode}, instance ${cipParam.instanceID}, attribute ${cipParam.attributeID}`, STD_LOGGING_REQUEST); + let cipData = []; + switch (cipParam.serviceCode) + { + case 0x4C:{ + cipData =[0x4C]; + cipData.push(cipParam.symbolSegment.length/2); + + cipParam.symbolSegment.forEach(element => { + cipData.push(element); + }); + + cipData.push(0x01); + cipData.push(0x00); + break; + } + default: + break; + } + log(`Symbol Object, OnCIPServiceRequest, cipdata ${Bytes2Str(cipData)}`, VERBOSE_LOGGING); + return cipData; + } + /** + * Processing the incoming data pacakge + * Reterive the attibutes and update the property of the class + * + * @param {CIPParam} cipParam + * @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). + * + **/ + OnCIPServiceResponse(cipParam, info){ + let data = info.data; + + info.tags.forEach(element => { + log(`SymbolObject Object, OnCIPServiceResponse: process the response for tag ${element.address}`, VERBOSE_LOGGING); + + }); + + let cipResponse = []; + for (let index = 48; index <= data.length; index ++){ + cipResponse.push(data[index]); + } + //log(`Symbol Object, OnCIPServiceResponse, cipResponse ${Bytes2Str(cipResponse)}`, VERBOSE_LOGGING); + + let servicecode = cipResponse[0]; + let statuscode = cipResponse[3]<<8|cipResponse[2]; + + let datatype = cipResponse[5]<<8|cipResponse[4]; + let value; + switch (datatype) + { + case 0x00C6: + case 0x00C2: + case 0x00C1:{ + value = cipResponse[6]; + break; + } + case 0x00C3:{ + value = (cipResponse[7]<<8)|cipResponse[6]; + break; + } + case 0x00D3: + case 0x00CA: + case 0x00C4:{ + value = (cipResponse[9]<<24)|(cipResponse[8]<<16)|(cipResponse[7]<<8)|cipResponse[6]; + break; + } + + default: + break; + } + + let cipRequestParam = GetCIPParamFromTagAddress(info.tags[0].address.toUpperCase()); + + + if(cipParam.symbolSegment.join() != cipRequestParam.symbolSegment.join()){ + + log(`SymbolObject Object, OnCIPServiceResponse: failed to process the response for Tag: ${info.tags[0].address}, as cip symbol segment ${cipParam.symbolSegment}, symbol segment from address ${cipRequestParam.symbolSegment}`, STD_LOGGING_RESPONSE); + return { action: ACTIONCOMPLETE}; + } + else{ + info.tags[0].dataType = "Byte" + info.tags[0].value = value; + + log(`SymbolObject Object, OnCIPServiceResponse: Success to process the response for Tag: ${info.tags[0].address}, Session ID: ${Bytes2Str(EIP_Session_ID)}`, STD_LOGGING_RESPONSE); + return { action: ACTIONCOMPLETE, tags: info.tags}; + + } + } +} 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..aea27a6 --- /dev/null +++ b/File Access/FileReader_FilePoll.md @@ -0,0 +1,167 @@ +# FileReader File 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 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..a2ee15f --- /dev/null +++ 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) 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 97af3fb..0000000 Binary files a/Generic Use Cases/CSV Data Import/kepware project/OPF - UDD for Unsolicited TCP Parser 202209.opf and /dev/null differ 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