s7webserverapi
Version:
Unofficial Simatic-S7-Webserver JSON-RPC-API Client for S7-1200/1500 PLCs
876 lines (875 loc) • 39.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.S7WebserverClient = void 0;
const rxjs_1 = require("rxjs");
const types_1 = require("../util/types");
const rxjs_http_client_1 = require("rxjs-http-client");
const WriteTransaction_1 = require("./WriteTransaction");
const CacheStructure_1 = require("./CacheStructure");
const murmurhash = require("murmurhash");
const Trie_1 = require("./Trie");
class S7WebserverClient {
baseUrl;
config;
http;
connectionErrorObservable = new rxjs_1.Subject();
onPlcConnectionReady = new rxjs_1.ReplaySubject();
loaded = false;
user = '';
token = '';
permissionMap = new Map();
permissionsSubject = new rxjs_1.ReplaySubject();
lastPollingTime = new Date();
pollingDelay;
slowPollingMode = false;
readStack = [];
lastReadStack = [];
cache = new CacheStructure_1.CacheStructure();
pollErrorSubject = new rxjs_1.Subject();
errorSubscriber = 0;
writeTransactionHandler = new WriteTransaction_1.WriteTransactionHandler();
getTransactionHandler = new WriteTransaction_1.GetTransactionHandler(this.writeTransactionHandler, this.cache);
subscriberCountMap = new Map();
rpcRequestHashedKeyMap = new Map();
getRequestLoadedSubject = new rxjs_1.Subject();
/**
* Because we use flattened keys, we use a Trie-Datastructure to keep track of the subscribers.
* When a leaf changes its value we can use the prefix-property of the trie to also call all the parents of the leaf.
*
* @protected
* @type {SubscriberTrie<typeof data>}
*/
subscriberTrie = new Trie_1.SubscriberTrie();
/**
* Subscriber trie for non cached values.
*
* @protected
* @type {SubscriberTrie<typeof data>}
*/
ignoreCacheSubscriberTrie = new Trie_1.SubscriberTrie();
browseFilesMap = new Map();
downloadFileMap = new Map();
browseTicketsCounter = 0;
browseTicketsMap = new Map();
closeTicketMap = new Map();
localStorage;
pollTimeout;
ticketApiUrl;
constructor(baseUrl, config, ticketApiUrl) {
this.baseUrl = baseUrl;
this.config = config;
this.config.localStoragePrefix = config.localStoragePrefix ?? 's7_';
this.config.defaultUser = config.defaultUser ?? { user: 'Anonymous', password: '' };
this.ticketApiUrl = this.baseUrl.replace('jsonrpc', 'ticket');
if (ticketApiUrl != undefined) {
this.ticketApiUrl = ticketApiUrl;
}
this.config.polling = config.polling ?? {
slowMinDelay: 1000 * 60, // Every minute
minDelay: 15,
emaAlpha: 0.1,
clamp: true
};
this.pollingDelay = this.config.polling.minDelay;
this.http = new rxjs_http_client_1.RxJSHttpClient();
this.localStorage = undefined;
if (Object.keys(this).includes("localStorage")) {
this.localStorage = this.localStorage;
}
this.initDefaultErrorHandler();
}
getFileDownloadTicket(path) {
return new rxjs_1.Observable(subscriber => {
if (this.downloadFileMap.has(path)) {
subscriber.error(`${path} already has an active download-request`);
subscriber.complete();
return;
}
this.downloadFileMap.set(path, new rxjs_1.Subject());
const x = this.downloadFileMap.get(path).subscribe(subscriber);
return () => {
x.unsubscribe();
};
});
}
downloadTicket(ticketId, type = 'text') {
return new rxjs_1.Observable(subscriber => {
this.checkStoredToken().pipe((0, rxjs_1.take)(1)).subscribe(() => {
const x = this.http.post(this.ticketApiUrl + `?id=${ticketId}`, {
headers: {
// "X-Auth-Token": this.token,
"Content-Type": "application/octet-stream"
},
// params: {
// id: ticketId
// }
})
.pipe((0, rxjs_1.mergeMap)(response => {
if (type == 'text') {
return response.text();
}
else if (type == 'json') {
return response.json();
}
else {
return response.arrayBuffer();
}
}))
.subscribe({
next: sub => subscriber.next(sub),
complete: () => {
this.closeTicket(ticketId).subscribe();
subscriber.complete();
},
error: err => subscriber.error(err)
});
return () => {
x.unsubscribe();
};
});
});
}
downloadFile(path, binary) {
if (!this.can('read_file')) {
return (0, rxjs_1.throwError)(() => new Error(`The current user ${this.user} can't read files`));
}
return new rxjs_1.Observable(subscriber => {
const x = this.getFileDownloadTicket(path).subscribe(ticket => {
const y = this.downloadTicket(ticket, binary ? 'arrayBuffer' : 'text').subscribe(subscriber);
return () => {
y.unsubscribe();
};
});
return () => {
x.unsubscribe();
};
});
}
downloadFolder(folderPath) {
if (!this.can('read_file')) {
return (0, rxjs_1.throwError)(() => new Error(`The current user ${this.user} can't read files`));
}
return new rxjs_1.Observable(subscriber => {
const x = this.browsePath(folderPath).subscribe(result => {
const observableArray = [];
const returnObject = [];
for (const entry of result) {
if (entry.type == 'dir' || entry.state == 'active') {
continue;
}
returnObject.push({ ...entry, data: "" });
observableArray.push(this.downloadFile(folderPath + '/' + entry.name));
}
const x = (0, rxjs_1.forkJoin)(observableArray).subscribe(files => {
for (let i = 0; i < files.length; i++) {
returnObject[i].data = files[i];
}
subscriber.next(returnObject);
subscriber.complete();
});
return () => {
x.unsubscribe();
};
});
return () => {
x.unsubscribe();
};
});
}
browsePath(path) {
return new rxjs_1.Observable(subscriber => {
if (this.browseFilesMap.has(path)) {
subscriber.error(`${path} already has an active FileBrowseRequest`);
subscriber.complete();
return;
}
this.browseFilesMap.set(path, new rxjs_1.Subject());
const x = this.browseFilesMap.get(path).subscribe(subscriber);
return () => {
x.unsubscribe();
};
});
}
closeAllTickets() {
return new rxjs_1.Observable(sub => this.browseTickets().subscribe(ticketResult => {
const obs = ticketResult.tickets.map(ticket => {
return this.closeTicket(ticket.id);
});
const x = (0, rxjs_1.forkJoin)(obs).subscribe(allClosedTickets => {
sub.next(allClosedTickets.every(x => x));
sub.complete();
});
() => {
x.unsubscribe();
};
}));
}
browseTickets() {
if (this.browseTicketsCounter >= 10) {
this.browseTicketsCounter = 0;
}
const id = `${this.browseTicketsCounter++}`;
if (this.browseTicketsMap.has(id)) {
return new rxjs_1.Observable(s => s.error(`Somehow there already is an browseTicket with id ${id} running`));
}
this.browseTicketsMap.set(id, new rxjs_1.Subject());
return this.browseTicketsMap.get(id).asObservable();
}
closeTicket(ticketId) {
const id = `${ticketId}`;
if (this.closeTicketMap.has(id)) {
return new rxjs_1.Observable(s => s.error(`Already trying to close the ticket with the id ${id}`));
}
this.closeTicketMap.set(id, new rxjs_1.Subject());
return this.closeTicketMap.get(id).asObservable();
}
get onPollError() {
this.errorSubscriber++;
return new rxjs_1.Observable(sub => {
const x = this.pollErrorSubject.subscribe(err => sub.next(err));
() => {
x.unsubscribe();
this.errorSubscriber--;
};
});
}
start() {
this.loadInitialCacheData();
this.initPLCPoll();
return this.onPlcConnectionReady.asObservable();
}
checkStoredToken() {
if (this.localStorage?.getItem(this.config.localStoragePrefix + 'token') != undefined && this.localStorage?.getItem(this.config.localStoragePrefix + 'user') != undefined) {
// Check if
const req = {
body: JSON.stringify(this.getRPCMethodObject(types_1.RPCMethods.GetPermissions, undefined, 'GETPERMISSIONS')),
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': this.localStorage?.getItem(this.config.localStoragePrefix + 'token') }
};
return this.http.post(this.baseUrl, req)
.pipe((0, rxjs_1.mergeMap)(response => response.json()))
.pipe((0, rxjs_1.map)((response) => {
if (response.error) {
return undefined;
}
if (response.result?.length === 0) {
return undefined;
}
else {
this.setCurrentPermissions(response.result);
this.token = this.localStorage?.getItem(this.config.localStoragePrefix + 'token');
this.user = this.localStorage?.getItem(this.config.localStoragePrefix + 'user');
return this.localStorage?.getItem(this.config.localStoragePrefix + 'user');
}
}));
}
else {
return new rxjs_1.Observable((subscriber) => {
subscriber.next();
});
}
}
/**
* Sets the current permissions and updates the permissions subject so other components can get a live-update of the permissions.
* E.g. if the user logs out, we may want to redirect them if theyre currently on a page they shouldnt access.
* @param permissions
*/
setCurrentPermissions(permissions) {
this.permissionMap.clear();
const perms = [];
for (const permission of permissions) {
this.permissionMap.set(permission.name, true);
perms.push(permission.name);
}
this.permissionsSubject.next(perms);
}
getRPCMethodObject(method, params, id = '0') {
return {
jsonrpc: "2.0",
method: method,
params: params,
id
};
}
initPLCPoll() {
this.checkStoredToken().pipe((0, rxjs_1.take)(1)).subscribe({
next: (value) => {
if (value == undefined) {
this.login().pipe((0, rxjs_1.take)(1)).subscribe({
next: () => {
this.pollData();
},
error: () => {
this.connectionErrorObservable.next(() => {
this.initPLCPoll();
});
}
});
}
else {
this.pollData();
}
},
error: () => {
this.connectionErrorObservable.next(() => {
this.initPLCPoll();
});
}
});
}
hashRPCMethods(set) {
for (const setEntry of set) {
setEntry.id = this.getHashedId(setEntry.id).toString();
}
}
getHashedId(humanReadableId) {
let hashedId = murmurhash(humanReadableId);
let counter = 0;
while (this.rpcRequestHashedKeyMap.has(hashedId) && this.rpcRequestHashedKeyMap.get(hashedId) !== humanReadableId) {
counter++;
hashedId = murmurhash(humanReadableId + counter.toString());
}
this.rpcRequestHashedKeyMap.set(hashedId, humanReadableId);
return hashedId;
}
handleRPCResponse(responses) {
if (this.loaded === false) {
this.onPlcConnectionReady.next(true);
this.loaded = true;
}
for (const responseKey in responses) {
const response = responses[responseKey];
const unhashedId = this.rpcRequestHashedKeyMap.get(+response.id);
if (unhashedId == undefined) {
throw new Error(`The Webserver-API returned an RPC-Result with an id that was not configured correctly (missed hash-id)`);
}
const responseSplit = unhashedId.split(":");
const command = responseSplit[0];
const key = responseSplit[1];
const additional = responseSplit[2];
if (response.error != undefined) {
this.handleRPCResponseError(unhashedId, response.error);
if (command === "WRITE") {
this.writeTransactionHandler.resolveDependentKey(Number(additional), key, false);
}
else if (command === "BROWSEFILES") {
this.browseFilesMap.get(key)?.complete();
this.browseFilesMap.delete(key);
}
else if (command === "DOWNLOADFILE") {
this.downloadFileMap.get(key)?.complete();
this.downloadFileMap.delete(key);
}
else if (command === "BROWSETICKETS") {
this.browseTicketsMap.get(key)?.complete();
this.browseTicketsMap.delete(key);
}
else if (command === "CLOSETICKET") {
this.closeTicketMap.get(key)?.complete();
this.closeTicketMap.delete(key);
}
continue;
}
switch (command) {
case "READ":
this.handleRPCResponseRead(key, response);
break;
case "WRITE":
this.handleRPCResponseWrite(key, response, Number(additional));
break;
case "BROWSEFILES": {
if (this.browseFilesMap.has(key)) {
this.browseFilesMap.get(key).next(response.result.resources);
this.browseFilesMap.get(key).complete();
this.browseFilesMap.delete(key);
}
else {
throw new Error("Getting a BrowseFiles-Response without an active BrowseFiles-Request");
}
break;
}
case "DOWNLOADFILE": {
if (this.downloadFileMap.has(key)) {
this.downloadFileMap.get(key).next(response.result);
this.downloadFileMap.get(key).complete();
this.downloadFileMap.delete(key);
}
else {
throw new Error("Getting a DownloadFile-Response without an active DownloadFile-Request");
}
break;
}
case "BROWSETICKETS": {
if (this.browseTicketsMap.has(key)) {
this.browseTicketsMap.get(key).next(response.result);
this.browseTicketsMap.get(key).complete();
this.browseTicketsMap.delete(key);
}
else {
throw new Error("Getting a BrowseTickets-Response without an active BrowseTickets-Request");
}
break;
}
case "CLOSETICKET": {
if (this.closeTicketMap.has(key)) {
this.closeTicketMap.get(key).next(response.result);
this.closeTicketMap.get(key).complete();
this.closeTicketMap.delete(key);
}
else {
throw new Error("Getting a CloseTicket-Response without an active CloseTicket-Request");
}
break;
}
}
}
}
handleRPCResponseRead(key, reponse) {
const oldValue = this.cache.getCopy(key);
this.cache.writeEntry(key, reponse.result);
if (oldValue !== this.cache.getReference(key)) {
this.subscriberTrie.notifySubscriber(key, this.cache.cacheObject);
}
this.ignoreCacheSubscriberTrie.notifySubscriber(key, this.cache.cacheObject);
}
handleRPCResponseWrite(key, response, writeTransactionId) {
const oldValue = this.cache.getCopy(key);
this.writeTransactionHandler.resolveDependentKey(writeTransactionId, key, response.result, this.cache);
if (oldValue !== this.cache.getReference(key)) {
this.subscriberTrie.notifySubscriber(key, this.cache.cacheObject);
}
this.ignoreCacheSubscriberTrie.notifySubscriber(key, this.cache.cacheObject);
}
/**
* Or better, maybe call the subscriber on error. Maybe create a error-subject that can be subscribed to and display the error somewhere in the UI.
* @param id rpc-id
* @param error RPC-Error Object
*/
handleRPCResponseError(id, error) {
switch (error.code) {
case types_1.RPCErrorCode.PERMISSON_DENIED: {
this.pollErrorSubject.next(`You're not allowed to execute this operation: ${id}`);
break;
}
case types_1.RPCErrorCode.ADRESS_NOT_FOUND: {
this.pollErrorSubject.next(`The address ${id} was not found in the PLC`);
break;
}
case types_1.RPCErrorCode.UNSUPPORTED_ADRESS: {
this.pollErrorSubject.next(`The adress ${id} is not reaching an atomic value like a Real, Int or String. But an Struct/Array. This is not supported by the API`);
}
default:
this.pollErrorSubject.next(`Error in RPC-Response with id: ${id} and error: ${error.code}: ${error.message}`);
;
}
}
// MARK: Polling-Cycle
/**
* This is the polling-cycle that will be called recursivley. It collects all the RPC-Methods that need to be called.
* It collects one time get- and write-requests and subscriber-requests. It then sends the requests to the PLC and collects them.
*
* If an network-error occurs, the connectionErrorObservable is called with a callback function. This is primarily used to retry connection attempts. Currently we display a error-toast with a retry button that calls this callback function.
*
*
* @returns
*/
pollData(once = false) {
this.lastPollingTime = new Date();
const headers = {
'Content-Type': 'application/json',
'X-Auth-Token': this.token
};
const jsonRPC = this.collectRPCMethodObjects();
/**
* The id is used for actually identifiying which logical request was made like READ:<hmi-key>
* This is good for the code and for debug purposes, however if we send multiple requests at once this id is really big
* An string can easily be 100+ chars long. So we hash it to a number, which reduces the data send over http.
*/
this.hashRPCMethods(jsonRPC);
this.http.post(this.baseUrl, { body: JSON.stringify(Array.from(jsonRPC)), headers })
.pipe((0, rxjs_1.switchMap)(res => res.json()))
.pipe((0, rxjs_1.take)(1)).subscribe({
next: (response) => {
this.handleRPCResponse(response);
this.getRequestLoadedSubject.next(true);
this.lastReadStack = [];
},
error: () => {
this.connectionErrorObservable.next(() => {
this.pollData(once);
});
},
complete: () => {
if (once) {
return;
}
this.recalculatePollingDelay();
this.pollTimeout = setTimeout(() => {
this.pollData();
}, this.pollingDelay);
}
});
}
recalculatePollingDelay() {
this.exponentialMovingAverage();
if (this.config.polling.clamp === true) {
this.clampPollingDelay();
}
}
exponentialMovingAverage() {
const timeDiff = new Date().getTime() - this.lastPollingTime.getTime();
const emaAlpha = this.config.polling.emaAlpha;
this.pollingDelay = emaAlpha * timeDiff + (1 - emaAlpha) * this.pollingDelay;
}
clampPollingDelay() {
if (this.slowPollingMode) {
this.pollingDelay = Math.max(this.config.polling.slowMinDelay, this.pollingDelay);
}
else {
this.pollingDelay = Math.max(this.config.polling.minDelay, this.pollingDelay);
}
}
collectFileRPCMethodObject(objectSet) {
for (const key of this.downloadFileMap.keys()) {
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.DownloadFile, { resource: key }, `DOWNLOADFILE:${key}`));
}
for (const key of this.browseFilesMap.keys()) {
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.BrowseFiles, { resource: key }, `BROWSEFILES:${key}`));
}
}
collectTicketRPCMethodObjects(objectSet) {
for (const key of this.closeTicketMap.keys()) {
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.CloseTicket, { id: key }, `CLOSETICKET:${key}`));
}
for (const key of this.browseTicketsMap.keys()) {
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.BrowseTickets, null, `BROWSETICKETS:${key}`));
}
}
/**
* Collects all the different RPC-Methods that should be called on a polling-cycle.
*
*/
collectRPCMethodObjects() {
const set = new Set();
this.collectGetRPCMethodObjects(set);
this.collectWriteRPCMethodObjects(set);
this.collectSubscribeRPCMethodObjects(set);
this.collectFileRPCMethodObject(set);
this.collectTicketRPCMethodObjects(set);
// IMPORTANT: This function should be the last function that is called in the collection of RPC Methods.
this.collectStaticRPCMethodObjects(set);
return set;
}
collectWriteRPCMethodObjects(objectSet) {
this.writeTransactionHandler.getAllTransactionsKeys().forEach(key => {
const transaction = this.writeTransactionHandler.getTransaction(key);
if (transaction == undefined) {
throw new Error(`Error while trying to collect the write RPC-Method Objects. The transaction with id ${key} is undefined.`);
}
this.collectChildrenKeys(transaction.keys[0], objectSet, types_1.RPCMethods.Write, Infinity, transaction.value, transaction);
});
}
collectSubscribeRPCMethodObjects(objectSet) {
Array.from(this.subscriberCountMap.entries()).filter(([, value]) => value > 0).forEach(([key,]) => {
// For every key in the subscriberCountMap that has a positive-counter value;
this.collectChildrenKeys(key, objectSet, types_1.RPCMethods.Read, Infinity);
});
}
/**
* When calling the get-Function, we just return the Subject in the subscriber-Trie and return the value once.
* Then we append the hmi-key value to our read-Stack. Which we then collect here, by filling in the children keys and calling the get-Method on the server for the key. Later we identify the results and write it into the cache, which then triggers the subject.
*
* In case we never successfully read, we save the lastReadStack and delete it after a successful polling-cycle. That way, if we retry the connection, we do not lose the read information.
* @param objectSet
*/
collectGetRPCMethodObjects(objectSet) {
this.getTransactionHandler.getAllTransactionsKeys().forEach(getTransactionId => {
const transaction = this.getTransactionHandler.getTransaction(getTransactionId);
if (transaction == undefined) {
throw new Error(`Error while trying to collect the get RPC-Method Objects. The transaction with id ${getTransactionId} is undefined.`);
}
for (const key of transaction.internalReadStack) {
this.collectChildrenKeys(key[0], objectSet, types_1.RPCMethods.Read, key[1], transaction.value, transaction);
}
});
// this.lastReadStack.forEach((element) => {
// this.collectChildrenKeys(element, objectSet, RPCMethods.Read);
// });
// this.readStack.forEach((element) => {
// this.collectChildrenKeys(element, objectSet, RPCMethods.Read);
// });
// // Clear the stack
// this.lastReadStack = this.readStack;
// this.readStack = [];
}
collectChildrenKeys(key, objectSet, method, depth, value, transaction) {
const plcKey = this.insertPrefixMapping(key);
const keys = this.cache.parseFlattenedKey(plcKey);
let ref = this.config.plcStructure;
if (ref == undefined) {
// This means, the user never configured the structure. So we cant fill in the details. We basically just create a read/write instruction for this key.
const plcVar = this.hmiKeyToPlcKey(key);
if (method === types_1.RPCMethods.Read) {
if (transaction != undefined) {
objectSet.add(this.getRPCMethodObject(method, { var: plcVar }, `READ:${key}:${transaction?.id}`));
transaction?.addDependentKey(key);
}
else {
objectSet.add(this.getRPCMethodObject(method, { var: plcVar }, `READ:${key}`));
}
}
else if (method === types_1.RPCMethods.Write) {
if (typeof value === 'object' || Array.isArray(value)) {
throw Error(`Trying to write the value ${value} to the key ${key}. The given value is an object and not a single value. You never specified the Structure of the PLC-DBs anywhere. Thus we cant fill in the missing keys here. Either you missed something, or you need to specify the plc-Structure and pass it into the constructor-config of the S7WebserverClient (config.plcStructure).`);
}
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.Write, { value: value ?? '', var: plcVar }, `WRITE:${key}:${transaction?.id}`));
transaction?.addDependentKey(key);
}
return;
}
for (const key of keys) {
ref = CacheStructure_1.CacheStructure.getNextReference(ref, key.toString());
}
// Call the recursion-call with newKey = '', so it just takes the first key as entrance
this._collectChildrenKeys(key, objectSet, ref, '', method, depth, value, transaction);
}
_collectChildrenKeys(wholeKey, objectSet, ref, newKey, method, depth, value, transaction) {
if (depth < 0) {
console.warn(`Depth reached!: ${wholeKey}`);
return;
}
if (method === types_1.RPCMethods.Write &&
(value == undefined || transaction == undefined)) {
throw new Error(`Error while trying to fill in children keys for JSONRPC-Request. Trying to create JSONRPC-Method Object to write to ${wholeKey}, but the relative value or writeTransaction is undefined!`);
}
ref = CacheStructure_1.CacheStructure.getNextReference(ref, newKey);
value = CacheStructure_1.CacheStructure.getNextReference(value, newKey);
if (Array.isArray(ref)) {
for (let i = 0; i < ref.length; i++) {
const newKey = wholeKey + `.${i}`;
if (ref[i] === undefined) {
ref[i] = '';
}
this._collectChildrenKeys(newKey, objectSet, ref, i.toString(), method, depth - 1, value, transaction);
}
return;
}
if (typeof ref === 'object') {
for (const key in ref) {
const newKey = wholeKey + `.${key}`;
this._collectChildrenKeys(newKey, objectSet, ref, key, method, depth - 1, value, transaction);
}
return;
}
if (ref === undefined) {
throw new Error(`Error while trying to fill in children keys for JSONRPC-Request. Trying to create JSONRPC-${method === types_1.RPCMethods.Write ? 'WRITE' : 'READ'}-Method Object to key "${newKey}" (wholeKey: ${wholeKey})! The relative reference to the PLC-DB-structure returned undefined. Which either means, a wrong key was provided that does not exist in the configured PLC-Structure, or the PLC-DB-Structure is faulty and the provided key is missing.`);
}
const plcVar = this.hmiKeyToPlcKey(wholeKey);
if (method === types_1.RPCMethods.Read) {
if (transaction != undefined) {
objectSet.add(this.getRPCMethodObject(method, { var: plcVar }, `READ:${wholeKey}:${transaction?.id}`));
transaction?.addDependentKey(wholeKey);
}
else {
objectSet.add(this.getRPCMethodObject(method, { var: plcVar }, `READ:${wholeKey}`));
}
}
else if (method === types_1.RPCMethods.Write) {
if (typeof value === 'object' || Array.isArray(value)) {
throw Error(`Error while trying to fill in children keys for JSONRPC-Request. Trying to create JSONRPC-Write-Method to key "${newKey}" (wholeKey: ${wholeKey}). According to the PLC-Structure this key is atomic and shouldnt be of type Array|Object. However the provided value: ${JSON.stringify(value)} appears to not be atomic.`);
}
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.Write, { value: value ?? '', var: plcVar }, `WRITE:${wholeKey}:${transaction?.id}`));
transaction?.addDependentKey(wholeKey);
}
}
/**
* Turns a HMI-Key into a PLC-Key. Given the information about the mapping of the HMI to the PLC.
* We also have to replace the Index-signatures for arrays with the correct syntax for the PLC (basically just wrapping the index in square bracktes and removing the dot before the square bracket) someObject.somearray.0 -> someObject.somearray[0]
* @param key Hmi-Key
* @returns
*/
hmiKeyToPlcKey(key) {
const mappedKey = this.insertPrefixMapping(key);
return mappedKey.split('.').map((element) => isNaN(Number(element)) ? element : `[${element}]`).join('.').replace(/\.\[/g, '[');
}
/**
* Inserts the PLC-Prefix based on the configured mapping.
* @param key Hmi-Key
* @returns PLC-Key
*/
insertPrefixMapping(key) {
// Go through the whole this.hmiPlcMapping and check if the key is a prefix of any key in the mapping. If it is, replace the prefix with the mapping. Go through the whole list first and look for the longest prefix. Use that one.
let longestPrefix = "";
for (const mappingKey in this.config.prefixSubstitutionMap) {
if (key.startsWith(mappingKey) && mappingKey.length > longestPrefix.length) {
longestPrefix = mappingKey;
}
}
if (longestPrefix === "") {
return key;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return key.replace(longestPrefix, this.config.prefixSubstitutionMap[longestPrefix]);
}
/**
* This function should be the last function that is called in the collection of RPC Methods.
* If the Set is empty by the time it reaches here, a ping gets added and a "slow Mode" is activated.
* When we dont subscribe to any values, we dont need to poll the PLC as often as usual and clamp the polling delay to a higher value. Because the only things we need to poll are errors and the ping basically.
*
* @param objectSet Object set that stores the RPC-Methods
*/
collectStaticRPCMethodObjects(objectSet) {
if (objectSet.size === 0) {
this.slowPollingMode = true;
objectSet.add(this.getRPCMethodObject(types_1.RPCMethods.Ping, undefined, 'PING'));
}
else {
this.slowPollingMode = false;
}
}
/**
* Calls the get-Function for each configured initial-Cache key.
* @returns
*/
loadInitialCacheData() {
if (this.config.initialCacheKeys == undefined)
return;
for (const cacheKey of this.config.initialCacheKeys) {
this.get(cacheKey).pipe((0, rxjs_1.take)(1)).subscribe();
}
}
hmiKeysLoaded(key) {
if (Array.isArray(key)) {
for (const singleKey of key) {
if (!this.cache.entryExists(singleKey)) {
return false;
}
}
return true;
}
return this.cache.entryExists(key);
}
toggleBackSlowMode() {
if (this.slowPollingMode === true) {
// Then last poll was slowmode, so recall it with fast mode
if (this.pollTimeout !== undefined) {
clearTimeout(this.pollTimeout);
}
this.pollingDelay = this.config.polling.minDelay;
this.pollTimeout = setTimeout(() => this.pollData(), this.pollingDelay);
}
this.slowPollingMode = false;
}
//MARK: GET
get(key, cacheMode, depth = Infinity) {
const keys = Array.isArray(key) ? key : [key];
return this.getTransactionHandler.createTransaction(keys, cacheMode, depth);
}
// protected _getFromCache<K>(keys: FlattenKeys<T>[]) {
// return new Observable<K>(subscriber => {
// subscriber.next(this.concatenateCacheFromKeys(keys) as K);
// subscriber.complete();
// })
// }
concatenateCacheFromKeys(keys) {
if (keys.length === 1) {
return this.cache.getCopy(keys[0]);
}
const concatenatedObject = {};
for (const key of keys) {
const keySplit = key.split('.');
let lastKey = keySplit[keySplit.length - 1];
let backIndex = 1;
while (concatenatedObject[lastKey] != undefined) {
if (keySplit[keySplit.length - 1 - backIndex]) {
throw new Error(`Trying to concatenate multiple read-key-results into a single Object. Encountered the error, that at least 2 keys have the same identifier. When asking for the keys ['someparent.x', 'someparent.y'] the resulting object will be {x: ..., y: ...}.\n however, if the keys are ['someparent.y', 'someotherparent.y'] y and y collides. So the keys are {"someparent.x": ..., "someparent.y": ...}. This error is displayed if both the keys will result in the same target-plc key (when using prefix-mapping).`);
}
lastKey = keySplit[keySplit.length - 1 - backIndex] + '.' + lastKey;
backIndex++;
}
concatenatedObject[lastKey] = this.cache.getCopy(key);
}
return concatenatedObject;
}
//MARK: WRITE
write(key, value) {
this.toggleBackSlowMode();
return this.writeTransactionHandler.createTransaction([key], value);
}
initDefaultErrorHandler() {
this.pollErrorSubject.subscribe(err => {
if (this.errorSubscriber == 0) {
console.error(err);
}
});
}
// MARK: SUBSCRIBE
subscribe(key, ignoreCache) {
const keys = Array.isArray(key) ? key : [key];
const subscriberObject = ignoreCache ? this.ignoreCacheSubscriberTrie : this.subscriberTrie;
return new rxjs_1.Observable(sub => {
const x = [];
if (!ignoreCache && this.hmiKeysLoaded(keys)) {
sub.next({ value: this.concatenateCacheFromKeys(keys), changedKey: '' });
}
keys.forEach(key => {
if (!subscriberObject.has(key)) {
subscriberObject.insert(key);
}
subscriberObject.incrementSubscriberCount(key);
if (this.subscriberCountMap.has(key)) {
this.subscriberCountMap.set(key, this.subscriberCountMap.get(key) + 1);
}
else {
this.subscriberCountMap.set(key, 1);
}
const subscription = subscriberObject.get(key).subscribe(value => {
sub.next({ value: this.concatenateCacheFromKeys(keys), changedKey: value.changedKey });
});
x.push(subscription);
});
this.toggleBackSlowMode();
return () => {
for (const key of keys) {
subscriberObject.decrementSubscriberCount(key);
if (this.subscriberCountMap.has(key)) {
this.subscriberCountMap.set(key, Math.max(0, this.subscriberCountMap.get(key) - 1));
}
}
x.forEach(sub => sub.unsubscribe());
};
});
}
get currentUser() {
return this.user;
}
can(permission) {
return this.permissionMap.get(permission) ?? false;
}
getPermissionsUpdates() {
return this.permissionsSubject.asObservable();
}
login(user = this.config.defaultUser.user, password = this.config.defaultUser.password) {
const req = {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify([
this.getRPCMethodObject(types_1.RPCMethods.Login, { user, password }, 'LOGIN'), this.getRPCMethodObject(types_1.RPCMethods.GetPermissions, undefined, 'GETPERMISSIONS')
])
};
return this.http.post(this.baseUrl, req).pipe((0, rxjs_1.switchMap)(res => res.json())).pipe((0, rxjs_1.map)(response => {
const loginResponse = response[0];
if (loginResponse.error) {
return loginResponse.error.code;
}
const permissionResult = response[1];
this.token = loginResponse.result.token;
this.user = user;
this.localStorage?.setItem(this.config.localStoragePrefix + 'token', this.token);
this.localStorage?.setItem(this.config.localStoragePrefix + 'user', this.user);
this.setCurrentPermissions(permissionResult.result);
return true;
}));
}
}
exports.S7WebserverClient = S7WebserverClient;