commit eae688c5fd757a1ce0de8658bb030b563ba50a0f Author: Patrick McDonagh Date: Wed Nov 15 21:07:23 2017 -0600 First commit of ProStar Solar Panel driver. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6952d1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ + +*.pyc +.vscode/settings.json diff --git a/html-templates/Alerts.html b/html-templates/Alerts.html new file mode 100644 index 0000000..2971cab --- /dev/null +++ b/html-templates/Alerts.html @@ -0,0 +1 @@ +Alerts diff --git a/html-templates/Device.html b/html-templates/Device.html new file mode 100644 index 0000000..5952db6 --- /dev/null +++ b/html-templates/Device.html @@ -0,0 +1,42 @@ +
+
+

Public IP Address

+

<%= channels["prostarsolar.public_ip_address"].value %>

+

+
+ + diff --git a/html-templates/NodeDetailHeader.html b/html-templates/NodeDetailHeader.html new file mode 100644 index 0000000..f731d4b --- /dev/null +++ b/html-templates/NodeDetailHeader.html @@ -0,0 +1,10 @@ +
+
+
+
+

<%= node.vanityname %>

+
+
+

Charge State: <%= channels['prostarsolar.charge_state'].value %>

+

Array Fault: <%= channels['prostarsolar.array_fault'].value %>

+
diff --git a/html-templates/Nodelist.html b/html-templates/Nodelist.html new file mode 100644 index 0000000..3686b71 --- /dev/null +++ b/html-templates/Nodelist.html @@ -0,0 +1,40 @@ + + +
+
+
+
+ +
+ +
+ +
+

<%= node.vanityname %>

+
+ +
+

Battery Voltage

+

<%= Math.round(channels['prostarsolar.adc_vbterm'].value * 100) / 100 %> V

+
+
+

Charge Current

+

<%= Math.round(channels['prostarsolar.adc_ia'].value * 100) / 100 %> A

+
+
diff --git a/html-templates/Overview.html b/html-templates/Overview.html new file mode 100644 index 0000000..e16b836 --- /dev/null +++ b/html-templates/Overview.html @@ -0,0 +1,273 @@ +
+
+

Array Current

+
+
+
+
+ + + +
+ + <%= channels["prostarsolar.adc_ia"].timestamp %> + +
+
+
+
+
+
+ +
+
+

Load Current

+
+
+
+
+ + + +
+ + <%= channels["prostarsolar.adc_il"].timestamp %> + +
+
+
+
+
+
+ +
+
+

Battery Voltage

+
+
+
+
+ + + +
+ + <%= channels["prostarsolar.adc_vbterm"].timestamp %> + +
+
+
+
+
+
+ +
+
+

Array Voltage

+
+
+
+
+ + + +
+ + <%= channels["prostarsolar.adc_va"].timestamp %> + +
+
+
+
+
+
+ +
+
+

Load Voltage

+
+
+
+
+ + + +
+ + <%= channels["prostarsolar.adc_vl"].timestamp %> + +
+
+
+
+
+
+ +
+
+

Ambient Temp

+
+
+
+
+ + + +
+ + <%= channels["prostarsolar.t_amb"].timestamp %> + +
+
+
+
+
+
+ + + + + + diff --git a/html-templates/Sidebar.html b/html-templates/Sidebar.html new file mode 100644 index 0000000..73737aa --- /dev/null +++ b/html-templates/Sidebar.html @@ -0,0 +1,15 @@ +" + class="data-table btn-block btn btn-theme animated" + title="Device Log"> Device Log + +" + data-techname="<%=channels["prostarsolar.sync"].techName %>" + data-name="<%= channels["prostarsolar.sync"].name%>" + data-nodechannelcurrentId="<%= channels["prostarsolar.sync"].nodechannelcurrentId %>" + id="<%= channels["prostarsolar.sync"].channelId %>" + class="btn btn-large btn-block btn-theme animated setstatic mqtt"> + Sync All Data diff --git a/html-templates/Trends.html b/html-templates/Trends.html new file mode 100644 index 0000000..1d4c878 --- /dev/null +++ b/html-templates/Trends.html @@ -0,0 +1,37 @@ +
+
+ + to + + + Run + +
+
+
+
+
+ diff --git a/logo.jpg b/logo.jpg new file mode 100644 index 0000000..2e2ceee Binary files /dev/null and b/logo.jpg differ diff --git a/python-driver/Channel.py b/python-driver/Channel.py new file mode 100644 index 0000000..0862eae --- /dev/null +++ b/python-driver/Channel.py @@ -0,0 +1,276 @@ +"""Define Meshify channel class.""" +from pycomm.ab_comm.clx import Driver as ClxDriver +from pycomm.cip.cip_base import CommError, DataError +import time + + +def binarray(intval): + """Split an integer into its bits.""" + bin_string = '{0:08b}'.format(intval) + bin_arr = [i for i in bin_string] + bin_arr.reverse() + return bin_arr + + +def read_tag(addr, tag): + """Read a tag from the PLC.""" + c = ClxDriver() + try: + if c.open(addr): + try: + v = c.read_tag(tag) + return v + except DataError: + c.close() + print("Data Error during readTag({}, {})".format(addr, tag)) + except CommError: + # err = c.get_status() + c.close() + print("Could not connect during readTag({}, {})".format(addr, tag)) + # print err + except AttributeError as e: + c.close() + print("AttributeError during readTag({}, {}): \n{}".format(addr, tag, e)) + c.close() + return False + + +def read_array(addr, tag, start, end): + """Read an array from the PLC.""" + c = ClxDriver() + if c.open(addr): + arr_vals = [] + try: + for i in range(start, end): + tag_w_index = tag + "[{}]".format(i) + v = c.read_tag(tag_w_index) + # print('{} - {}'.format(tag_w_index, v)) + arr_vals.append(round(v[0], 4)) + # print(v) + if len(arr_vals) > 0: + return arr_vals + else: + print("No length for {}".format(addr)) + return False + except Exception: + print("Error during readArray({}, {}, {}, {})".format(addr, tag, start, end)) + err = c.get_status() + c.close() + print err + pass + c.close() + + +def write_tag(addr, tag, val): + """Write a tag value to the PLC.""" + c = ClxDriver() + if c.open(addr): + try: + cv = c.read_tag(tag) + wt = c.write_tag(tag, val, cv[1]) + return wt + except Exception: + print("Error during writeTag({}, {}, {})".format(addr, tag, val)) + err = c.get_status() + c.close() + print err + c.close() + + +class Channel(object): + """Holds the configuration for a Meshify channel.""" + + def __init__(self, mesh_name, data_type, chg_threshold, guarantee_sec, map_=False, write_enabled=False): + """Initialize the channel.""" + self.mesh_name = mesh_name + self.data_type = data_type + self.last_value = None + self.value = None + self.last_send_time = 0 + self.chg_threshold = chg_threshold + self.guarantee_sec = guarantee_sec + self.map_ = map_ + self.write_enabled = write_enabled + + def __str__(self): + """Create a string for the channel.""" + return "{}\nvalue: {}, last_send_time: {}".format(self.mesh_name, self.value, self.last_send_time) + + def check(self, new_value, force_send=False): + """Check to see if the new_value needs to be stored.""" + send_needed = False + send_reason = "" + if self.data_type == 'BOOL' or self.data_type == 'STRING': + if self.last_send_time == 0: + send_needed = True + send_reason = "no send time" + elif self.value is None: + send_needed = True + send_reason = "no value" + elif not (self.value == new_value): + if self.map_: + if not self.value == self.map_[new_value]: + send_needed = True + send_reason = "value change" + else: + send_needed = True + send_reason = "value change" + elif (time.time() - self.last_send_time) > self.guarantee_sec: + send_needed = True + send_reason = "guarantee sec" + elif force_send: + send_needed = True + send_reason = "forced" + else: + if self.last_send_time == 0: + send_needed = True + send_reason = "no send time" + elif self.value is None: + send_needed = True + send_reason = "no value" + elif abs(self.value - new_value) > self.chg_threshold: + send_needed = True + send_reason = "change threshold" + elif (time.time() - self.last_send_time) > self.guarantee_sec: + send_needed = True + send_reason = "guarantee sec" + elif force_send: + send_needed = True + send_reason = "forced" + if send_needed: + self.last_value = self.value + if self.map_: + try: + self.value = self.map_[new_value] + except KeyError: + print("Cannot find a map value for {} in {} for {}".format(new_value, self.map_, self.mesh_name)) + self.value = new_value + else: + self.value = new_value + self.last_send_time = time.time() + print("Sending {} for {} - {}".format(self.value, self.mesh_name, send_reason)) + return send_needed + + def read(self): + """Read the value.""" + pass + +def identity(sent): + """Returns exactly what was sent to it.""" + return sent + +class ModbusChannel(Channel): + """Modbus channel object.""" + + def __init__(self, mesh_name, register_number, data_type, chg_threshold, guarantee_sec, map_=False, write_enabled=False, transformFn=identity): + """Initialize the channel.""" + super(ModbusChannel, self).__init__(mesh_name, data_type, chg_threshold, guarantee_sec, map_, write_enabled) + self.mesh_name = mesh_name + self.register_number = register_number + self.data_type = data_type + self.last_value = None + self.value = None + self.last_send_time = 0 + self.chg_threshold = chg_threshold + self.guarantee_sec = guarantee_sec + self.map_ = map_ + self.write_enabled = write_enabled + self.transformFn = transformFn + + def read(self, mbsvalue): + return self.transformFn(mbsvalue) + + +class PLCChannel(Channel): + """PLC Channel Object.""" + + def __init__(self, ip, mesh_name, plc_tag, data_type, chg_threshold, guarantee_sec, map_=False, write_enabled=False): + """Initialize the channel.""" + super(PLCChannel, self).__init__(mesh_name, data_type, chg_threshold, guarantee_sec, map_, write_enabled) + self.plc_ip = ip + self.mesh_name = mesh_name + self.plc_tag = plc_tag + self.data_type = data_type + self.last_value = None + self.value = None + self.last_send_time = 0 + self.chg_threshold = chg_threshold + self.guarantee_sec = guarantee_sec + self.map_ = map_ + self.write_enabled = write_enabled + + def read(self): + """Read the value.""" + plc_value = None + if self.plc_tag and self.plc_ip: + read_value = read_tag(self.plc_ip, self.plc_tag) + if read_value: + plc_value = read_value[0] + + return plc_value + +class BoolArrayChannels(Channel): + """Hold the configuration for a set of boolean array channels.""" + def __init__(self, ip, mesh_name, plc_tag, data_type, chg_threshold, guarantee_sec, map_=False, write_enabled=False): + """Initialize the channel.""" + self.plc_ip = ip + self.mesh_name = mesh_name + self.plc_tag = plc_tag + self.data_type = data_type + self.last_value = None + self.value = None + self.last_send_time = 0 + self.chg_threshold = chg_threshold + self.guarantee_sec = guarantee_sec + self.map_ = map_ + self.write_enabled = write_enabled + + def compare_values(self, new_val_dict): + """Compare new values to old values to see if the values need storing.""" + send = False + for idx in new_val_dict: + try: + if new_val_dict[idx] != self.last_value[idx]: + send = True + except KeyError: + print("Key Error in self.compare_values for index {}".format(idx)) + send = True + return send + + def read(self, force_send=False): + """Read the value and check to see if needs to be stored.""" + send_needed = False + send_reason = "" + if self.plc_tag: + v = read_tag(self.plc_ip, self.plc_tag) + if v: + bool_arr = binarray(v[0]) + new_val = {} + for idx in self.map_: + try: + new_val[self.map_[idx]] = bool_arr[idx] + except KeyError: + print("Not able to get value for index {}".format(idx)) + + if self.last_send_time == 0: + send_needed = True + send_reason = "no send time" + elif self.value is None: + send_needed = True + send_reason = "no value" + elif self.compare_values(new_val): + send_needed = True + send_reason = "value change" + elif (time.time() - self.last_send_time) > self.guarantee_sec: + send_needed = True + send_reason = "guarantee sec" + elif force_send: + send_needed = True + send_reason = "forced" + + if send_needed: + self.value = new_val + self.last_value = self.value + self.last_send_time = time.time() + print("Sending {} for {} - {}".format(self.value, self.mesh_name, send_reason)) + return send_needed diff --git a/python-driver/Maps.py b/python-driver/Maps.py new file mode 100644 index 0000000..049acda --- /dev/null +++ b/python-driver/Maps.py @@ -0,0 +1,47 @@ +"""Holds map values for prostarsolar.""" + +def charge_state(inp_state): + """Map function for charge state.""" + states = { + 0: "Start", + 1: "Night Check", + 2: "Disconnect", + 3: "Night", + 4: "Fault", + 5: "Bulk", + 6: "Absorption", + 7: "Float", + 8: "Equalize" + } + if inp_state in range(0,9): + return states[inp_state] + else: + return inp_state + + +def array_faults(inp_array_faults): + """Form a string for the array_faults.""" + fault_string = "" + faults = { + 0: "Overcurrent Phase 1", + 1: "FETs Shorted", + 2: "Software Bug", + 3: "Battery HVD (High Voltage Disconnect)", + 4: "Array HVD (High Voltage Disconnect)", + 5: "EEPROM Setting Edit (reset required)", + 6: "RTS Shorted", + 7: "RTS was valid now disconnected", + 8: "Local temp. sensor failed", + 9: "Battery LVD (Low Voltage Disconect)", + 10: "DIP Switch Changed (excl. DIP 8)", + 11: "Processor Supply Fault" + } + + bit_string = ("0" * 16 + "{0:b}".format(inp_array_faults))[-16:] + for i in range(0, 12): + if int(bit_string[i]) == 1: + fault_string += faults[i] + ", " + if fault_string: + return fault_string[:-2] + else: + return "None" \ No newline at end of file diff --git a/python-driver/config.txt b/python-driver/config.txt new file mode 100644 index 0000000..3a17168 --- /dev/null +++ b/python-driver/config.txt @@ -0,0 +1,14 @@ +{ + +"driverFileName":"prostarsolar.py", +"deviceName":"prostarsolar", +"driverId":"0160", +"releaseVersion":"1", +"files": { + "file1":"prostarsolar.py", + "file2":"Channel.py", + "file3":"Maps.py", + "file4":"Scheduler.py" + } + +} diff --git a/python-driver/driverConfig.json b/python-driver/driverConfig.json new file mode 100644 index 0000000..53ed824 --- /dev/null +++ b/python-driver/driverConfig.json @@ -0,0 +1,13 @@ +{ + "name": "prostarsolar", + "driverFilename": "prostarsolar.py", + "driverId": "0000", + "additionalDriverFiles": [ + "utilities.py", + "persistence.py", + "Channel.py", + "Maps.py" + ], + "version": 1, + "s3BucketName": "prostarsolar" +} \ No newline at end of file diff --git a/python-driver/persistence.py b/python-driver/persistence.py new file mode 100644 index 0000000..ed65271 --- /dev/null +++ b/python-driver/persistence.py @@ -0,0 +1,21 @@ +"""Data persistance functions.""" +# if more advanced persistence is needed, use a sqlite database +import json + + +def load(filename="persist.json"): + """Load persisted settings from the specified file.""" + try: + with open(filename, 'r') as persist_file: + return json.load(persist_file) + except Exception: + return False + + +def store(persist_obj, filename="persist.json"): + """Store the persisting settings into the specified file.""" + try: + with open(filename, 'w') as persist_file: + return json.dump(persist_obj, persist_file) + except Exception: + return False diff --git a/python-driver/prostarsolar.py b/python-driver/prostarsolar.py new file mode 100644 index 0000000..1878d35 --- /dev/null +++ b/python-driver/prostarsolar.py @@ -0,0 +1,157 @@ +"""Driver for prostarsolar.""" + +import threading +from device_base import deviceBase +from Channel import read_tag, write_tag +from Channel import ModbusChannel +from Maps import charge_state, array_faults +import persistence +from random import randint +from utilities import get_public_ip_address, int_to_float16 +import json +import time + +import minimalmodbus +import minimalmodbusM1 + +_ = None + +# GLOBAL VARIABLES +WATCHDOG_SEND_PERIOD = 3600 # Seconds, the longest amount of time before sending the watchdog status +PLC_IP_ADDRESS = "192.168.1.10" +CHANNELS = [ + ModbusChannel("adc_ia", 17, "REAL", 0.5, 3600, transformFn=int_to_float16), + ModbusChannel("adc_vbterm", 18, "REAL", 0.5, 3600, transformFn=int_to_float16), + ModbusChannel("adc_va", 19, "REAL", 0.5, 3600, transformFn=int_to_float16), + ModbusChannel("adc_vl", 20, "REAL", 0.5, 3600, transformFn=int_to_float16), + ModbusChannel("adc_il", 22, "REAL", 0.5, 3600, transformFn=int_to_float16), + ModbusChannel("t_amb", 28, "REAL", 2.0, 3600, transformFn=int_to_float16), + ModbusChannel("vb_min_daily", 65, "REAL", 2.0, 3600, transformFn=int_to_float16), + ModbusChannel("vb_max_daily", 66, "REAL", 2.0, 3600, transformFn=int_to_float16), + ModbusChannel('charge_state', 33, "STRING", 1, 3600, transformFn=charge_state), + ModbusChannel('array_fault', 34, "STRING", 1, 3600, transformFn=array_faults) +] + +# PERSISTENCE FILE +persist = persistence.load() + + +class start(threading.Thread, deviceBase): + """Start class required by Meshify.""" + + def __init__(self, name=None, number=None, mac=None, Q=None, mcu=None, companyId=None, offset=None, mqtt=None, Nodes=None): + """Initialize the driver.""" + threading.Thread.__init__(self) + deviceBase.__init__(self, name=name, number=number, mac=mac, Q=Q, mcu=mcu, companyId=companyId, offset=offset, mqtt=mqtt, Nodes=Nodes) + + self.daemon = True + self.version = "1" + self.finished = threading.Event() + self.forceSend = False + threading.Thread.start(self) + + # this is a required function for all drivers, its goal is to upload some piece of data + # about your device so it can be seen on the web + def register(self): + """Register the driver.""" + # self.sendtodb("log", "BOOM! Booted.", 0) + pass + + def run(self): + """Actually run the driver.""" + global persist + wait_sec = 60 + for i in range(0, wait_sec): + print("prostarsolar driver will start in {} seconds".format(wait_sec - i)) + time.sleep(1) + print("BOOM! Starting prostarsolar driver...") + self.nodes["prostarsolar_0199"] = self + + public_ip_address = get_public_ip_address() + self.sendtodbDev(1, 'public_ip_address', public_ip_address, 0, 'prostarsolar') + # watchdog = self.prostarsolar_watchdog() + # self.sendtodbDev(1, 'watchdog', watchdog, 0, 'prostarsolar') + # watchdog_send_timestamp = time.time() + + connected_to_485 = False + while connected_to_485 is False: + connected_to_485 = self.mcu.set485Baud(9600) + + serial_485 = self.mcu.rs485 + instrument_485 = minimalmodbusM1.Instrument(1, serial_485) + instrument_485.address = 1 + + send_loops = 0 + watchdog_loops = 0 + watchdog_check_after = 5000 + while True: + if self.forceSend: + print "FORCE SEND: TRUE" + + for chan in CHANNELS: + try: + val = chan.read(instrument_485.read_register(chan.register_number, functioncode=4)) + if chan.check(val, self.forceSend): + self.sendtodbDev(1, chan.mesh_name, chan.value, 0, 'prostarsolar') + time.sleep(0.1) + except IOError as e: + print("IO Error: {}".format(e)) + print("Attempting to reconnect to rs485 device") + connected_to_485 = False + while connected_to_485 is False: + connected_to_485 = self.mcu.set485Baud(9600) + + serial_485 = self.mcu.rs485 + instrument_485 = minimalmodbusM1.Instrument(1, serial_485) + instrument_485.address = 1 + + # print("prostarsolar driver still alive...") + if self.forceSend: + if send_loops > 2: + print("Turning off forceSend") + self.forceSend = False + send_loops = 0 + else: + send_loops += 1 + + watchdog_loops += 1 + if (watchdog_loops >= watchdog_check_after): + # test_watchdog = self.prostarsolar_watchdog() + # if not test_watchdog == watchdog or (time.time() - watchdog_send_timestamp) > WATCHDOG_SEND_PERIOD: + # self.sendtodbDev(1, 'watchdog', test_watchdog, 0, 'prostarsolar') + # watchdog = test_watchdog + + test_public_ip = get_public_ip_address() + if not test_public_ip == public_ip_address: + self.sendtodbDev(1, 'public_ip_address', test_public_ip, 0, 'prostarsolar') + public_ip_address = test_public_ip + watchdog_loops = 0 + time.sleep(10) + + def prostarsolar_watchdog(self): + """Write a random integer to the PLC and then 1 seconds later check that it has been decremented by 1.""" + randval = randint(0, 32767) + write_tag(str(PLC_IP_ADDRESS), 'watchdog_INT', randval) + time.sleep(1) + watchdog_val = read_tag(str(PLC_IP_ADDRESS), 'watchdog_INT') + try: + return (randval - 1) == watchdog_val[0] + except (KeyError, TypeError): + return False + + def prostarsolar_sync(self, name, value): + """Sync all data from the driver.""" + self.forceSend = True + # self.sendtodb("log", "synced", 0) + return True + + def prostarsolar_writeplctag(self, name, value): + """Write a value to the PLC.""" + new_val = json.loads(str(value).replace("'", '"')) + tag_n = str(new_val['tag']) # "cmd_Start" + val_n = new_val['val'] + w = write_tag(str(PLC_IP_ADDRESS), tag_n, val_n) + print("Result of prostarsolar_writeplctag(self, {}, {}) = {}".format(name, value, w)) + if w is None: + w = "Error writing to PLC..." + return w diff --git a/python-driver/utilities.py b/python-driver/utilities.py new file mode 100644 index 0000000..cff4f19 --- /dev/null +++ b/python-driver/utilities.py @@ -0,0 +1,40 @@ +"""Utility functions for the driver.""" +import socket + + +def get_public_ip_address(): + """Find the public IP Address of the host device.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + + +def int_to_float16(int_to_convert): + """Convert integer into float16 representation.""" + bin_rep = ('0' * 16 + '{0:b}'.format(int_to_convert))[-16:] + sign = 1 + if int(bin_rep[0]) == 1: + sign = -1 + exponent = int(bin_rep[1:6], 2) + fraction = int(bin_rep[7:17], 2) + + return sign * 2 ** (exponent - 15) * float("1.{}".format(fraction)) + + +def degf_to_degc(temp_f): + """Convert deg F to deg C.""" + return (temp_f - 32.0) * (5.0/9.0) + + +def degc_to_degf(temp_c): + """Convert deg C to deg F.""" + return temp_c * 1.8 + 32.0 + +def reverse_map(value, map_): + """Perform the opposite of mapping to an object.""" + for x in map_: + if map_[x] == value: + return x + return None