From eae688c5fd757a1ce0de8658bb030b563ba50a0f Mon Sep 17 00:00:00 2001 From: Patrick McDonagh Date: Wed, 15 Nov 2017 21:07:23 -0600 Subject: [PATCH] First commit of ProStar Solar Panel driver. --- .gitignore | 3 + html-templates/Alerts.html | 1 + html-templates/Device.html | 42 ++++ html-templates/NodeDetailHeader.html | 10 + html-templates/Nodelist.html | 40 ++++ html-templates/Overview.html | 273 ++++++++++++++++++++++++++ html-templates/Sidebar.html | 15 ++ html-templates/Trends.html | 37 ++++ logo.jpg | Bin 0 -> 6878 bytes python-driver/Channel.py | 276 +++++++++++++++++++++++++++ python-driver/Maps.py | 47 +++++ python-driver/config.txt | 14 ++ python-driver/driverConfig.json | 13 ++ python-driver/persistence.py | 21 ++ python-driver/prostarsolar.py | 157 +++++++++++++++ python-driver/utilities.py | 40 ++++ 16 files changed, 989 insertions(+) create mode 100644 .gitignore create mode 100644 html-templates/Alerts.html create mode 100644 html-templates/Device.html create mode 100644 html-templates/NodeDetailHeader.html create mode 100644 html-templates/Nodelist.html create mode 100644 html-templates/Overview.html create mode 100644 html-templates/Sidebar.html create mode 100644 html-templates/Trends.html create mode 100644 logo.jpg create mode 100644 python-driver/Channel.py create mode 100644 python-driver/Maps.py create mode 100644 python-driver/config.txt create mode 100644 python-driver/driverConfig.json create mode 100644 python-driver/persistence.py create mode 100644 python-driver/prostarsolar.py create mode 100644 python-driver/utilities.py 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 0000000000000000000000000000000000000000..2e2ceeeab2fa436ba9b0b7d508c991762064f67a GIT binary patch literal 6878 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 literal 0 HcmV?d00001 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