Authentication help in communication with fronius gen24 webinterface

hello,

I tried to packt this code

into a node red function.

i tried with node red http request node, with authentication, but can't get it running.

so I used an online code converter from python to javascript and adapted afterwards the code to have it maybe working. :roll_eyes:

the http request does not work at all, so I added nodejs http module to globals - and I still can't get it running, more worse, node red crashes as (I guess) nodejs http opens a connection and leaves it open - and on the next request it dies there.

20 Apr 18:31:00 - [red] Uncaught Exception:
20 Apr 18:31:00 - [error] Error: socket hang up
at connResetException (node:internal/errors:704:14)
at Socket.socketOnEnd (node:_http_client:505:23)
at Socket.emit (node:events:525:35)
at endReadableNT (node:internal/streams/readable:1359:12)
at processTicksAndRejections (node:internal/process/task_queues:82:21)
nodered.service: Main process exited, code=exited, status=1/FAILURE
nodered.service: Failed with result 'exit-code'.

I am quite sure, its not intended to be used this way.
maybe somebody can either help me replacing the http request in line 312 with an better function, so I can experiment further, or help me with a "node red" way speaking to the webinterface with the inverter?

Thanks. :slight_smile:

[{"id":"226590f61c67af28","type":"function","z":"df7c9eb28d26a1c2","d":true,"name":"converttest","func":"//from https://github.com/muexxl/batcontrol/blob/main/fronius/fronius.py\n\nfunction hash_utf8(x) {\n  if (typeof x === 'string') {\n    x = x.encode('utf-8');\n  }\n  var crypto = require('crypto');\n  return crypto.createHash('md5').update(x).digest(\"hex\");;\n}\n\nclass FroniusWR {\n  constructor(address, user, password) {\n    this.login_attempts = 0;\n    this.address = '192.168.0.249';\n    this.capacity = -1;\n    this.max_charge_rate = 4000;\n    this.max_grid_power = 5000;\n    this.nonce = 0;\n    this.user = 'technican';\n    this.password = 'mypassword';\n    this.previous_config = this.get_battery_config();\n    if (!this.previous_config) {\n      throw new Error(`[Fronius] failed to load Battery config from Inverter at ${this.address}`);\n    }\n    this.min_soc = this.previous_config['BAT_M0_SOC_MIN'];\n    this.max_soc = this.previous_config['BAT_M0_SOC_MAX'];\n    this.get_time_of_use();\n  }\n\n  get_SOC() {\n    const path = '/solar_api/v1/GetPowerFlowRealtimeData.fcgi';\n    const response = this.send_request(path);\n    if (!response) {\n      node.error('[Fronius] Failed to get SOC. Returning default value of 99.0');\n      return 99.0;\n    }\n    const result = JSON.parse(response.text);\n    const soc = result['Body']['Data']['Inverters']['1']['SOC'];\n    return soc;\n  }\n\n  get_free_capacity() {\n    const current_soc = this.get_SOC();\n    const capa = this.get_capacity();\n    const free_capa = (this.max_soc - current_soc) / 100 * capa;\n    return free_capa;\n  }\n\n  get_stored_energy() {\n    const current_soc = this.get_SOC();\n    const capa = this.get_capacity();\n    const energy = (current_soc - this.min_soc) / 100 * capa;\n    return energy;\n  }\n\n  get_usable_capacity() {\n    const usable_capa = (this.max_soc - this.min_soc) / 100 * this.get_capacity();\n    return usable_capa;\n  }\n\n  get_battery_config() {\n    const path = '/config/batteries';\n    const response = this.send_request(path, true);\n    if (!response) {\n      node.error('[Fronius] Failed to get SOC. Returning empty dict');\n      return {};\n    }\n    const result = JSON.parse(response.text);\n    flow.set('Batteryconfig', result)\n    return result;\n  }\n\n  restore_battery_config() {\n    const settings_to_restore = [\n      'BAT_M0_SOC_MAX',\n      'BAT_M0_SOC_MIN',\n      'BAT_M0_SOC_MODE',\n      'HYB_BM_CHARGEFROMAC',\n      'HYB_EM_MODE',\n      'HYB_EM_POWER',\n      'HYB_EVU_CHARGEFROMGRID'\n    ];\n    const settings = {};\n    for (const key in settings_to_restore) {\n      if (key in this.previous_config) {\n        settings[key] = this.previous_config[key];\n      } else {\n        throw new Error(`Unable to restore settings. Parameter ${key} is missing`);\n      }\n    }\n    const path = '/config/batteries';\n    const payload = JSON.stringify(settings);\n    node.warn(`[Fronius] Restoring previous battery configuration: ${payload} `);\n    const response = this.send_request(path, 'POST', payload, true);\n    if (!response) {\n      throw new Error('failed to restore battery config');\n    }\n    const response_dict = JSON.parse(response.text);\n    const expected_write_successes = settings_to_restore;\n    for (const expected_write_success in expected_write_successes) {\n      if (!(expected_write_success in response_dict['writeSuccess'])) {\n        throw new Error(`failed to set ${expected_write_success}`);\n      }\n    }\n    return response;\n  }\n\n  set_allow_grid_charging(value) {\n    let payload;\n    if (value) {\n      payload = '{\"HYB_EVU_CHARGEFROMGRID\": true}';\n    } else {\n      payload = '{\"HYB_EVU_CHARGEFROMGRID\": false}';\n    }\n    const path = '/config/batteries';\n    const response = this.send_request(path, 'POST', payload, true);\n    const response_dict = JSON.parse(response.text);\n    const expected_write_successes = ['HYB_EVU_CHARGEFROMGRID'];\n    for (const expected_write_success in expected_write_successes) {\n      if (!(expected_write_success in response_dict['writeSuccess'])) {\n        throw new Error(`failed to set ${expected_write_success}`);\n      }\n    }\n    return response;\n  }\n\n  set_wr_parameters(minsoc, maxsoc, allow_grid_charging, grid_power) {\n    const path = '/config/batteries';\n    if (typeof allow_grid_charging !== 'boolean') {\n      throw new Error(`Expected type: bool actual type: ${typeof allow_grid_charging}`);\n    }\n    grid_power = parseInt(grid_power);\n    minsoc = parseInt(minsoc);\n    maxsoc = parseInt(maxsoc);\n    if (!(0 <= grid_power && grid_power  <= this.max_charge_rate)) {\n      throw new Error(`gridpower out of allowed limits ${grid_power}`);\n    }\n    if (minsoc > maxsoc) {\n      throw new Error('Min SOC needs to be higher than Max SOC');\n    }\n    if (minsoc < this.min_soc) {\n      throw new Error(`Min SOC not allowed below ${this.min_soc}`);\n    }\n    if (maxsoc > this.max_soc) {\n      throw new Error(`Max SOC not allowed above ${this.max_soc}`);\n    }\n    const parameters = {\n      'HYB_EVU_CHARGEFROMGRID': allow_grid_charging,\n      'HYB_EM_POWER': grid_power,\n      'HYB_EM_MODE': 1,\n      'BAT_M0_SOC_MIN': minsoc,\n      'BAT_M0_SOC_MAX': maxsoc,\n      'BAT_M0_SOC_MODE': 'manual'\n    };\n    const payload = JSON.stringify(parameters);\n    node.warn(`[Fronius] Setting battery parameters: ${payload} `);\n    const response = this.send_request(path, 'POST', payload, true);\n    if (!response) {\n      node.error('[Fronius] Failed to set parameters. No response from server');\n      return response;\n    }\n    const response_dict = JSON.parse(response.text);\n    for (const expected_write_success in parameters) {\n      if (!(expected_write_success in response_dict['writeSuccess'])) {\n        throw new Error(`failed to set ${expected_write_success}`);\n      }\n    }\n    return response;\n  }\n\n  get_time_of_use() {\n    const response = this.send_request('/config/timeofuse', true);\n    if (!response) {\n      return null;\n    }\n    const result = JSON.parse(response.text)['timeofuse'];\n    flow.set('time_of_use_config_json', result)\n    //TODO: do not overwrite!!!\n    return result;\n  }\n\n  set_mode_avoid_discharge() {\n    const timeofuselist = [{\n      'Active': true,\n      'Power': 0,\n      'ScheduleType': 'DISCHARGE_MAX',\n      'TimeTable': {\n        'End': '23:59',\n        'Start': '00:00'\n      },\n      'Weekdays': {\n        'Fri': true,\n        'Mon': true,\n        'Sat': true,\n        'Sun': true,\n        'Thu': true,\n        'Tue': true,\n        'Wed': true\n      }\n    }];\n    this.set_allow_grid_charging(false);\n    return this.set_time_of_use(timeofuselist);\n  }\n\n  set_mode_allow_discharge() {\n    this.set_allow_grid_charging(false);\n    const response = this.set_time_of_use([]); //set empty time of use list to remove all rules\n    return response;\n  }\n\n  set_mode_force_charge(chargerate = 500) {\n    const timeofuselist = [{\n      'Active': true,\n      'Power': chargerate,\n      'ScheduleType': 'CHARGE_MIN',\n      'TimeTable': {\n        'End': '23:59',\n        'Start': '00:00'\n      },\n      'Weekdays': {\n        'Fri': true,\n        'Mon': true,\n        'Sat': true,\n        'Sun': true,\n        'Thu': true,\n        'Tue': true,\n        'Wed': true\n      }\n    }];\n    this.set_allow_grid_charging(true);\n    return this.set_time_of_use(timeofuselist);\n  }\n\n  restore_time_of_use_config() {\n    let time_of_use_config_json = flow.get(\"time_of_use_config_json\") || 0;\n    if (time_of_use_config_json == 0) {\n      node.error('[Fronius] could not restore timeofuse config');\n      return;\n    }\n    let time_of_use_config;\n    try {\n      time_of_use_config = JSON.parse(time_of_use_config_json);\n    } catch (err) {\n      node.error(`[Fronius] could not parse timeofuse config from time_of_use_config_json`);\n      return;\n    }\n    const stripped_time_of_use_config = [];\n    for (const listitem in time_of_use_config) {\n      const new_item = {};\n      new_item['Active'] = listitem['Active'];\n      new_item['Power'] = listitem['Power'];\n      new_item['ScheduleType'] = listitem['ScheduleType'];\n      new_item['TimeTable'] = {\n        'Start': listitem['TimeTable']['Start'],\n        'End': listitem['TimeTable']['End']\n      };\n      const weekdays = {};\n      for (const day in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) {\n        weekdays[day] = listitem['Weekdays'][day];\n      }\n      new_item['Weekdays'] = weekdays;\n      stripped_time_of_use_config.push(new_item);\n    }\n    this.set_time_of_use(stripped_time_of_use_config);\n  }\n\n  set_time_of_use(timeofuselist) {\n    const config = {\n      'timeofuse': timeofuselist\n    };\n    const payload = JSON.stringify(config);\n    const response = this.send_request('/config/timeofuse', 'POST', payload, true);\n    const response_dict = JSON.parse(response.text);\n    const expected_write_successes = ['timeofuse'];\n    for (const expected_write_success in expected_write_successes) {\n      if (!(expected_write_success in response_dict['writeSuccess'])) {\n        throw new Error(`failed to set ${expected_write_success}`);\n      }\n    }\n    return response;\n  }\n\n  get_capacity() {\n    if (this.capacity >= 0) {\n      return this.capacity;\n    }\n    const response = this.send_request('/solar_api/v1/GetStorageRealtimeData.cgi');\n    if (!response) {\n      node.warn('[Fronius] capacity request failed. Returning default value');\n      return 1000;\n    }\n    const result = JSON.parse(response.text);\n    const capacity = result['Body']['Data']['0']['Controller']['DesignedCapacity'];\n    this.capacity = capacity;\n    return capacity;\n  }\n\n  send_request(path, method = 'GET', payload = '', params = null, headers = {}, auth = false) {\n    for (let i = 0; i < 1; i++) {\n      const url = 'http://' + this.address + path;\n      let fullpath = path;\n      if (params) {\n        fullpath += '?' + Object.keys(params).map(k => `${k}=${params[k]}`).join('&');\n      }\n      node.warn('url: ' + url)\n      if (auth) {\n        headers['Authorization'] = this.get_auth_header(method, fullpath);\n      }\n      node.warn('auth: ' + auth)\n      // try {\n        var http = global.get('http');\n        var options = {method: 'GET', headers: {headers}}\n        let response = http.request(url, options);\n        //let response = requests.request(method, url, params, headers, payload);\n        node.warn(response)\n        if (response.statusCode === 200) {\n          return response;\n        } else if (response.statusCode === 401) {\n          this.nonce = this.get_nonce(response);\n          if (this.login_attempts >= 3) {\n            node.warn('[Fronius] Login failed 3 times .. aborting');\n            throw new Error('[Fronius] Login failed repeatedly .. wrong credentials?');\n          }\n          response = this.login();\n          if (response.statusCode === 200) {\n            node.warn('[Fronius] Login successful');\n            this.login_attempts = 0;\n          } else {\n            node.warn('[Fronius] Login failed');\n          }\n        } else {\n          throw new Error(`Server ${this.address} returned ${response.statusCode}`);\n        }\n      // } catch (err) {\n      //   node.error(`[Fronius] Connection to Inverter failed on ${this.address}. Retrying in 120 seconds`);\n      //   // const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n      //   // await sleep(20000)\n      // }\n    }\n    let response = null;\n    return response;\n  }\n\n  login() {\n    const params = {\n      'user': this.user\n    };\n    const path = '/commands/Login';\n    this.login_attempts += 1;\n    return this.send_request(path, true);\n  }\n\n  logout() {\n    const params = {\n      'user': this.user\n    };\n    const path = '/commands/Logout';\n    const response = this.send_request(path, true);\n    if (!response) {\n      node.warn('[Fronius] Logout failed. No response from server');\n    }\n    if (response.statusCode === 200) {\n      node.warn('[Fronius] Logout successful');\n    } else {\n      node.warn('[Fronius] Logout failed');\n    }\n    return response;\n  }\n\n  get_nonce(response) {\n    let auth_string;\n    if ('X-WWW-Authenticate' in response.headers) {\n      auth_string = response.headers['X-WWW-Authenticate'];\n    } else if ('X-Www-Authenticate' in response.headers) {\n      auth_string = response.headers['X-Www-Authenticate'];\n    } else {\n      auth_string = '';\n    }\n    const auth_list = auth_string.replace(' ', '').replace('\"', '').split(',');\n    const auth_dict = {};\n    for (const item in auth_list) {\n      const [key, value] = item.split('=');\n      auth_dict[key] = value;\n    }\n    return auth_dict['nonce'];\n  }\n\n  get_auth_header(method, path) {\n    const nonce = this.nonce;\n    const realm = 'Webinterface area';\n    const ncvalue = '00000001';\n    const cnonce = 'NaN';\n    const user = this.user;\n    const password = this.password;\n    if (this.user.length < 4) {\n      throw new Error('User needed for Authorization');\n    }\n    if (this.password.length < 4) {\n      throw new Error('Password needed for Authorization');\n    }\n    const A1 = `${user}:${realm}:${password}`;\n    const A2 = `${method}:${path}`;\n    const HA1 = hash_utf8(A1);\n    const HA2 = hash_utf8(A2);\n    const noncebit = `${nonce}:${ncvalue}:${cnonce}:auth:${HA2}`;\n    const respdig = hash_utf8(`${HA1}:${noncebit}`);\n    const auth_header = `Digest username=\"${user}\", realm=\"${realm}\", nonce=\"${nonce}\", uri=\"${path}\", algorithm=\"MD5\", qop=auth, nc=${ncvalue}, cnonce=\"${cnonce}\", response=\"${respdig}\"`;\n    return auth_header;\n  }\n\n  __del__() {\n    this.restore_battery_config();\n    this.restore_time_of_use_config();\n    this.logout();\n  }\n}\n\nvar tst = new FroniusWR();\nvar tst2 = tst.get_battery_config();\n\nnode.warn(tst2);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":830,"y":140,"wires":[[]]}]

wohoo, I came a step ahead. I managed to do the authentication in a function based on the response of the http request node. it stores the calculated authentication header and on the nxt request to the same node it injects it.

[{"id":"d4a9518723057862","type":"http request","z":"df7c9eb28d26a1c2","name":"","method":"use","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[{"keyType":"Authorization","keyValue":"","valueType":"msg","valueValue":"authorization"}],"x":390,"y":140,"wires":[["4dcaecea99c00a32","8267fa7e4fbbdd3c"]]},{"id":"692cc626391a6c14","type":"inject","z":"df7c9eb28d26a1c2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":120,"y":40,"wires":[["24e00417d5661829"]]},{"id":"4dcaecea99c00a32","type":"debug","z":"df7c9eb28d26a1c2","name":"debug 6","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":560,"y":200,"wires":[]},{"id":"8267fa7e4fbbdd3c","type":"function","z":"df7c9eb28d26a1c2","name":"401-authentication-needed","func":"if (msg.statusCode == 401) {\n  var path = msg.responseUrl.slice(msg.responseUrl.indexOf('/',9))\n  var authentication = get_auth_header('GET', path)\n  flow.set('gen24auth.'+path.replaceAll('/','_'), authentication)\n}\n\nfunction grepnonce(){\n  var authheader = ''\n  authheader = msg.headers[\"x-www-authenticate\"]\n  if (authheader == '') {\n    node.error('could not find x-www-authenticate')\n    return\n  }\n  var authheaderlist = authheader.split(', ');\n  var headers = {}\n  for (var item in authheaderlist) {\n    var [key, value] = authheaderlist[item].split('=');\n    headers[key] = value.replaceAll('\"', '');\n  }\n  return headers['nonce']\n}\n\nfunction hash_utf8(x) {\n  if (typeof x === 'string') {\n    x = Buffer.from(x, 'utf-8').toString();\n  }\n  var crypto = global.get('crypto');\n  var hash = crypto.createHash('md5').update(x).digest(\"hex\");\n  return hash;\n}\n\nfunction get_auth_header(method, path) {\n  var nonce = grepnonce();\n  var user = msg.user;\n  var password = msg.password;\n  const realm = 'Webinterface area';\n  const ncvalue = '00000001';\n  const cnonce = 'NaN';\n  if (user.length < 4) {\n    node.error('User needed for Authorization')\n    return;\n  }\n  if (password.length < 4) {\n    node.error('Password needed for Authorization')\n    return;\n  }\n  var A1 = user + ':' + realm + ':' + password;\n  var A2 = method + ':' + path;\n  var HA1 = hash_utf8(A1);\n  var HA2 = hash_utf8(A2);\n  var noncebit = nonce + ':' + ncvalue + ':' + cnonce + ':auth:' + HA2;\n  var respdig = hash_utf8(HA1 + ':' + noncebit);\n  var auth_header = 'Digest username=\"' + user + '\", realm=\"' + realm + '\", nonce=\"' + nonce + '\", uri=\"' + path + '\", algorithm=\"MD5\", qop=auth, nc=\"' + ncvalue + '\", cnonce=\"' + cnonce + '\", response=\"' + respdig + '\"';\n  node.warn(auth_header)\n  return auth_header;\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":620,"y":140,"wires":[[]]},{"id":"2523e18242b837f3","type":"function","z":"df7c9eb28d26a1c2","name":"setURL","func":"msg.method = 'GET';\nmsg.url = msg.host + msg.path;\nmsg.authorization = flow.get('gen24auth.'+msg.path.replaceAll('/','_'))\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":700,"y":40,"wires":[["d4a9518723057862"]]},{"id":"7d9919f95be66514","type":"function","z":"df7c9eb28d26a1c2","name":"set_host","func":"msg.host = 'http://192.168.0.249';\nmsg.user = 'technician';\nmsg.password = 'mypassword';\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":520,"y":40,"wires":[["2523e18242b837f3"]]},{"id":"24e00417d5661829","type":"function","z":"df7c9eb28d26a1c2","name":"set_path_batteries","func":"msg.path = '/config/batteries';\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":40,"wires":[["7d9919f95be66514"]]},{"id":"7155f8d8b887cb4d","type":"function","z":"df7c9eb28d26a1c2","name":"set_path_timeofuse","func":"msg.path = '/config/timeofuse';\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":80,"wires":[["7d9919f95be66514"]]},{"id":"191a1d40fdd75d38","type":"inject","z":"df7c9eb28d26a1c2","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":120,"y":80,"wires":[["7155f8d8b887cb4d"]]}]

so, one part left, which I could not generate without external dependencies: the hashing function:

function hash_utf8(x) {
  if (typeof x === 'string') {
    x = Buffer.from(x, 'utf-8').toString();
  }
  var crypto = global.get('crypto');
  var hash = crypto.createHash('md5').update(x).digest("hex");
  return hash;
}

can somebody help me out how to solve this without using an external module?

also, if there are more "node red" way instead of the functions I generated, please be so kind and tell me. I just do not know how to solve it without those functions. :slight_smile:

thanks in advance! :+1:

ok, I solved this too, found a md5 function which works the same way ... :slight_smile:

1 Like