#!/usr/bin/env python
# -*- coding: utf-8 -*-

'''
Python 2.7/Python 3.5/3.6 (thanks to pschmitt for adding Python 3 compatibility)
Program to connect to Roomba 980 vacuum cleaner, dcode json, and forward to mqtt
server

Nick Waterton 24th April 2017: V 1.0: Initial Release
Nick Waterton 4th July   2017  V 1.1.1: Fixed MQTT protocol version, and map
paths, fixed paho-mqtt tls changes
Nick Waterton 5th July   2017  V 1.1.2: Minor fixes, CV version 3 .2 support
Nick Waterton 7th July   2017  V1.2.0: Added -o option "roomOutline" allows
enabling/disabling of room outline drawing, added auto creation of css/html files
Nick Waterton 11th July  2017  V1.2.1: Quick (untested) fix for room outlines
if you don't have OpenCV
Nick Waterton 3rd Feb  2018  V1.2.2: Quick (untested) fix for running directly (ie not installed)
Nick Waterton 12th April 2018 V1.2.3: Fixed image rotation bug causing distorted maps if map rotation was not 0.
Nick Waterton 21st Dec 2018 V1.2.4: Fixed problem with findContours with OpenCV V4. Note V4.0.0-alpha still returns 3 values, and so won't work.
Nick Wateton 7th Oct 2019 V1.2.5: changed PROTOCOL_TLSv1 to PROTOCOL_TLS to fix i7 connection problem after F/W upgrade.
Nick Waterton 12th Nov 2019 V1.2.6: added set_ciphers('DEFAULT@SECLEVEL=1') to ssl context to work arounf dh_key_too_small error.
Nick Waterton 14th Jan 2020 V1.2.7: updated error code list.
Nick Waterton 16th march 2020 V 1.2.8 fixed __version__ for Pillow v7 (replaced with __version__)
Nick Waterton 24th April 2020 V 1.2.9 added possibility to send json commands to Roomba
'''

from __future__ import print_function
from __future__ import absolute_import
__version__ = "1.2.9"

from ast import literal_eval
from collections import OrderedDict, Mapping
try:
    from roomba.password import Password
except ImportError:
    from password import Password
import datetime
import json
try:
    json_parse_exception = json.decoder.JSONDecodeError
except AttributeError:  # Python 2
    json_parse_exception = ValueError
import math
import logging
import os
import six
import socket
import ssl
import sys
import threading
import time
import traceback
try:
    import configparser
except:
    from six.moves import configparser

# Import trickery
global HAVE_CV2
global HAVE_MQTT
global HAVE_PIL
HAVE_CV2 = False
HAVE_MQTT = False
HAVE_PIL = False
try:
    import paho.mqtt.client as mqtt
    HAVE_MQTT = True
except ImportError:
    print("paho mqtt client not found")
try:
    import cv2
    import numpy as np
    HAVE_CV2 = True
except ImportError:
    print("CV or numpy module not found, falling back to PIL")

# NOTE: MUST use Pillow Pillow 4.1.1 or above to avoid some horrible memory leaks in the
# text handling!
try:
    from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
    HAVE_PIL = True
except ImportError:
    print("PIL module not found, maps are disabled")

# On Python 3 raw_input was renamed to input
try:
    input = raw_input
except NameError:
    pass


class Roomba(object):
    '''
    This is a Class for Roomba 900 series WiFi connected Vacuum cleaners
    Requires firmware version 2.0 and above (not V1.0). Tested with Roomba 980
    username (blid) and password are required, and can be found using the
    password() class above (or can be auto discovered)
    Most of the underlying info was obtained from here:
    https://github.com/koalazak/dorita980 many thanks!
    The values received from the Roomba as stored in a dictionay called
    master_state, and can be accessed at any time, the contents are live, and
    will build with time after connection.
    This is not needed if the forward to mqtt option is used, as the events will
    be decoded and published on the designated mqtt client topic.
    '''

    VERSION = "1.1"

    states = {"charge": "Charging",
              "new": "New Mission",
              "run": "Running",
              "resume": "Running",
              "hmMidMsn": "Recharging",
              "recharge": "Recharging",
              "stuck": "Stuck",
              "hmUsrDock": "User Docking",
              "dock": "Docking",
              "dockend": "Docking - End Mission",
              "cancelled": "Cancelled",
              "stop": "Stopped",
              "pause": "Paused",
              "hmPostMsn": "End Mission",
              "":  None}

    # From http://homesupport.irobot.com/app/answers/detail/a_id/9024/~/roomba-900-error-messages
    _ErrorMessages_old = {
        0: "None",
        1: "Roomba is stuck with its left or right wheel hanging down.",
        2: "The debris extractors can't turn.",
        5: "The left or right wheel is stuck.",
        6: "The cliff sensors are dirty, it is hanging over a drop, "\
           "or it is stuck on a dark surface.",
        8: "The fan is stuck or its filter is clogged.",
        9: "The bumper is stuck, or the bumper sensor is dirty.",
        10: "The left or right wheel is not moving.",
        11: "Roomba has an internal error.",
        14: "The bin has a bad connection to the robot.",
        15: "Roomba has an internal error.",
        16: "Roomba has started while moving or at an angle, or was bumped "\
            "while running.",
        17: "The cleaning job is incomplete.",
        18: "Roomba cannot return to the Home Base or starting position."
    }
    
    # from decoding app
    _ErrorMessages = {
        0: "None",
        1: "Left wheel off floor",
        2: "Main Brushes stuck",
        3: "Right wheel off floor",
        4: "Left wheel stuck",
        5: "Right wheel stuck",
        6: "Stuck near a cliff",
        7: "Left wheel error",
        8: "Bin error",
        9: "Bumper stuck",
        10: "Right wheel error",
        11: "Bin error",
        12: "Cliff sensor issue",
        13: "Both wheels off floor",
        14: "Bin missing",
        15: "Reboot required",
        16: "Bumped unexpectedly",
        17: "Path blocked",
        18: "Docking issue",
        19: "Undocking issue",
        20: "Docking issue",
        21: "Navigation problem",
        22: "Navigation problem", 
        23: "Battery issue",
        24: "Navigation problem",
        25: "Reboot required",
        26: "Vacuum problem",
        27: "Vacuum problem",
        29: "Software update needed",
        30: "Vacuum problem",
        31: "Reboot required",
        32: "Smart map problem",
        33: "Path blocked",
        34: "Reboot required",
        35: "Unrecognised cleaning pad",
        36: "Bin full",
        37: "Tank needed refilling",
        38: "Vacuum problem",
        39: "Reboot required",
        40: "Navigation problem",
        41: "Timed out",
        42: "Localization problem",
        43: "Navigation problem",
        44: "Pump issue",
        45: "Lid open",
        46: "Low battery",
        47: "Reboot required",
        48: "Path blocked",
        52: "Pad required attention",
        65: "Hardware problem detected",
        66: "Low memory",
        68: "Hardware problem detected",
        73: "Pad type changed",
        74: "Max area reached",
        75: "Navigation problem",
        76: "Hardware problem detected"
    }

    def __init__(self, address=None, blid=None, password=None, topic="#",
                 continuous=True, clean=False, cert_name="", roombaName="",
                 file="./config.ini"):
        '''
        address is the IP address of the Roomba, the continuous flag enables a
        continuous mqtt connection, if this is set to False, the client connects
        and disconnects every 'delay' seconds (1 by default, but can be
        changed). This is to allow other programs access, as there can only be
        one Roomba connection at a time.
        As cloud connections are unaffected, I reccomend leaving this as True.
        leave topic as is, unless debugging (# = all messages).
        if a python standard logging object exists, it will be used for logging.
        '''

        self.debug = False
        self.log = logging.getLogger("roomba.__main__") #modified to work with new scheme NW 15/9/2017
        #self.log = logging.getLogger(__name__+'.Roomba')
        if self.log.getEffectiveLevel() == logging.DEBUG:
            self.debug = True
        self.address = address
        if not cert_name:
            self.cert_name = "/etc/ssl/certs/ca-certificates.crt"
        else:
            self.cert_name = cert_name
        self.continuous = continuous
        if self.continuous:
            self.log.info("CONTINUOUS connection")
        else:
            self.log.info("PERIODIC connection")
        # set the following to True to enable pretty printing of json data
        self.pretty_print = False
        self.stop_connection = False
        self.periodic_connection_running = False
        self.clean = clean
        self.roomba_port = 8883
        self.blid = blid
        self.password = password
        self.roombaName = roombaName
        self.topic = topic
        self.mqttc = None
        self.exclude = ""
        self.delay = 1
        self.roomba_connected = False
        self.indent = 0
        self.master_indent = 0
        self.raw = False
        self.drawmap = False
        self.previous_co_ords = self.co_ords = self.zero_coords()
        self.fnt = None
        self.home_pos = None
        self.angle = 0
        self.cleanMissionStatus_phase = ""
        self.previous_cleanMissionStatus_phase = ""
        self.current_state = None
        self.last_completed_time = None
        self.bin_full = False
        self.base = None #base map
        self.dock_icon = None   #dock icon
        self.roomba_icon = None #roomba icon
        self.roomba_cancelled_icon = None #roomba cancelled icon
        self.roomba_battery_icon = None #roomba battery low icon
        self.roomba_error_icon = None #roomba error icon
        self.bin_full_icon = None #bin full icon
        self.room_outline_contour = None
        self.room_outline = None
        self.transparent = (0, 0, 0, 0)  #transparent
        self.previous_display_text = self.display_text = None
        self.master_state = {}
        self.time = time.time()
        self.update_seconds = 300   #update with all values every 5 minutes
        self.show_final_map = True
        self.client = None

        if self.address is None or blid is None or password is None:
            self.read_config_file(file)

    def read_config_file(self, file="./config.ini"):
        #read config file
        Config = configparser.ConfigParser()
        try:
            Config.read(file)
        except Exception as e:
            self.log.warn("Error reading config file %s" %e)
            self.log.info("No Roomba specified, and no config file found - "
                          "attempting discovery")
            if Password(self.address, file):
                return self.read_config_file(file)
            else:
                return False
        self.log.info("reading info from config file %s" % file)
        addresses = Config.sections()
        if self.address is None and len(addresses):
            if len(addresses) > 1:
                self.log.warn("config file has entries for %d Roombas, "
                              "only configuring the first!")
            self.address = addresses[0]
        if self.address:
            self.blid = Config.get(self.address, "blid")
            self.password = Config.get(self.address, "password")
        else:
            self.log.warn("Error reading config file %s" % file)
            return False
        # self.roombaName = literal_eval(
        #     Config.get(self.address, "data"))["robotname"]
        return True

    def setup_client(self):
        if self.client is None:
            if not HAVE_MQTT:
                print("Please install paho-mqtt 'pip install paho-mqtt' "
                      "to use this library")
                return False
            self.client = mqtt.Client(
                client_id=self.blid, clean_session=self.clean,
                protocol=mqtt.MQTTv311)
            # Assign event callbacks
            self.client.on_message = self.on_message
            self.client.on_connect = self.on_connect
            self.client.on_publish = self.on_publish
            self.client.on_subscribe = self.on_subscribe
            self.client.on_disconnect = self.on_disconnect

            # Uncomment to enable debug messages
            # client.on_log = self.on_log

            # set TLS, self.cert_name is required by paho-mqtt, even if the
            # certificate is not used...
            # but v1.3 changes all this, so have to do the following:

            self.log.info("Setting TLS")
            try:
                self.client.tls_set(
                    self.cert_name, cert_reqs=ssl.CERT_NONE,
                    tls_version=ssl.PROTOCOL_TLS, ciphers='DEFAULT@SECLEVEL=1')
            except (ValueError, FileNotFoundError):   # try V1.3 version
                self.log.warn("TLS Setting failed - trying 1.3 version")
                self.client._ssl_context = None
                context = ssl.SSLContext(ssl.PROTOCOL_TLS)
                context.verify_mode = ssl.CERT_NONE
                context.load_default_certs()
                context.set_ciphers('DEFAULT@SECLEVEL=1')  # NW added 12/11/2019
                self.client.tls_set_context(context)
            except:
                self.log.error("Error setting TLS: %s" % traceback.format_exc())

            # disables peer verification
            self.client.tls_insecure_set(True)
            self.client.username_pw_set(self.blid, self.password)
            self.log.info("Setting TLS - OK")
            return True
        return False

    def connect(self):
        if self.address is None or self.blid is None or self.password is None:
            self.log.critical("Invalid address, blid, or password! All these "
                              "must be specified!")
            sys.exit(1)
        if self.roomba_connected or self.periodic_connection_running: return

        if self.continuous:
            if not self._connect():
                if self.mqttc is not None:
                    self.mqttc.disconnect()
                sys.exit(1)
        else:
            self._thread = threading.Thread(target=self.periodic_connection)
            self._thread.daemon = True
            self._thread.start()

        self.time = time.time()   #save connect time

    def _connect(self, count=0, new_connection=False):
        max_retries = 3
        try:
            if self.client is None or new_connection:
                self.log.info("Connecting %s" % self.roombaName)
                self.setup_client()
                self.client.connect(self.address, self.roomba_port, 60)
            else:
                self.log.info("Attempting to Reconnect %s" % self.roombaName)
                self.client.loop_stop()
                self.client.reconnect()
            self.client.loop_start()
            return True
        except Exception as e:
            self.log.error("Error: %s " % e)
            exc_type, exc_obj, exc_tb = sys.exc_info()
            # self.log.error("Exception: %s" % exc_type)
            # if e[0] == 111: #errno.ECONNREFUSED - does not work with
            # python 3.0 so...
            if exc_type == socket.error or exc_type == ConnectionRefusedError:
                count += 1
                if count <= max_retries:
                    self.log.error("Attempting new Connection# %d" % count)
                    time.sleep(1)
                    self._connect(count, True)
        if count == max_retries:
            self.log.error("Unable to connect %s" % self.roombaName)
        return False

    def disconnect(self):
        if self.continuous:
            self.client.disconnect()
        else:
            self.stop_connection = True

    def periodic_connection(self):
        # only one connection thread at a time!
        if self.periodic_connection_running: return
        self.periodic_connection_running = True
        while not self.stop_connection:
            if self._connect():
                time.sleep(self.delay)
                self.client.disconnect()
            time.sleep(self.delay)

        self.client.disconnect()
        self.periodic_connection_running = False

    def on_connect(self, client, userdata, flags, rc):
        self.log.info("Roomba Connected %s" % self.roombaName)
        if rc == 0:
            self.roomba_connected = True
            self.client.subscribe(self.topic)
        else:
            self.log.error("Roomba Connected with result code " + str(rc))
            self.log.error("Please make sure your blid and password are "
                           "correct %s" % self.roombaName)
            if self.mqttc is not None:
               self.mqttc.disconnect()
            sys.exit(1)

    def on_message(self, mosq, obj, msg):
        # print(msg.topic + " " + str(msg.qos) + " " + str(msg.payload))
        if self.exclude != "":
            if self.exclude in msg.topic:
                return

        if self.indent == 0:
            self.master_indent = max(self.master_indent, len(msg.topic))

        log_string, json_data = self.decode_payload(msg.topic,msg.payload)
        self.dict_merge(self.master_state, json_data)

        if self.pretty_print:
            self.log.info("%-{:d}s : %s".format(self.master_indent)
                          % (msg.topic,log_string))
        else:
            self.log.info("Received Roomba Data %s: %s, %s"
                          % (self.roombaName, str(msg.topic), str(msg.payload)))

        if self.raw:
            self.publish(msg.topic, msg.payload)
        else:
            self.decode_topics(json_data)

        # default every 5 minutes
        if time.time() - self.time > self.update_seconds:
            self.log.info("Publishing master_state %s" % self.roombaName)
            self.decode_topics(self.master_state)    # publish all values
            self.time = time.time()

    def on_publish(self, mosq, obj, mid):
        pass

    def on_subscribe(self, mosq, obj, mid, granted_qos):
        self.log.debug("Subscribed: %s %s" % (str(mid), str(granted_qos)))

    def on_disconnect(self, mosq, obj, rc):
        self.roomba_connected = False
        if rc != 0:
            self.log.warn("Unexpected Disconnect From Roomba %s! - reconnecting"
                          % self.roombaName)
        else:
            self.log.info("Disconnected From Roomba %s" % self.roombaName)

    def on_log(self, mosq, obj, level, string):
        self.log.info(string)

    def set_mqtt_client(self, mqttc=None, brokerFeedback=""):
        self.mqttc = mqttc
        if self.mqttc is not None:
            if self.roombaName != "":
                self.brokerFeedback = brokerFeedback + "/" + self.roombaName
            else:
                self.brokerFeedback = brokerFeedback

    def send_command(self, command):
        self.log.info("Received COMMAND: %s" % command)
        Command = OrderedDict()
        try:
            Command = json.loads(command, object_pairs_hook=OrderedDict)
        except json_parse_exception:
            Command["command"] = command
        Command["time"] = self.totimestamp(datetime.datetime.now())
        Command["initiator"] = "localApp"
        myCommand = json.dumps(Command)
        self.log.info("Publishing Roomba Command : %s" % myCommand)
        self.client.publish("cmd", myCommand)

    def set_preference(self, preference, setting):
        self.log.info("Received SETTING: %s, %s" % (preference, setting))
        val = False
        if setting.lower() == "true":
            val = True
        tmp = {preference: val}
        Command = {"state": tmp}
        myCommand = json.dumps(Command)
        self.log.info("Publishing Roomba Setting : %s" % myCommand)
        self.client.publish("delta", myCommand)

    def publish(self, topic, message):
        if self.mqttc is not None and message is not None:
            self.log.debug("Publishing item: %s: %s"
                           % (self.brokerFeedback + "/" + topic, message))
            self.mqttc.publish(self.brokerFeedback + "/" + topic, message)

    def set_options(self, raw=False, indent=0, pretty_print=False):
        self.raw = raw
        self.indent = indent
        self.pretty_print = pretty_print
        if self.raw:
            self.log.info("Posting RAW data")
        else:
            self.log.info("Posting DECODED data")

    def enable_map(self, enable=False, mapSize="(800,1500,0,0,0,0)",
                   mapPath=".", iconPath = "./", roomOutline=True,
                   enableMapWithText=True,
                   fillColor="lawngreen", 
                   outlineColor=(64,64,64,255),
                   outlineWidth=1,
                   home_icon_file="home.png",
                   roomba_icon_file="roomba.png",
                   roomba_error_file="roombaerror.png",
                   roomba_cancelled_file="roombacancelled.png",
                   roomba_battery_file="roomba-charge.png",
                   bin_full_file="binfull.png",
                   roomba_size=(50,50), draw_edges = 30, auto_rotate=True):
        '''
        Enable live map drawing. mapSize is x,y size, x,y offset of docking
        station ((0,0) is the center of the image) final value is map rotation
        (in case map is not straight up/down). These values depend on the
        size/shape of the area Roomba covers. Offset depends on where you place
        the docking station. This will need some experimentation to get right.
        You can supply 32x32 icons for dock and roomba etc. If the files don't
        exist, crude representations are made. If you specify home_icon_file as
        None, then no dock is drawn. Draw edges attempts to draw straight lines
        around the final (not live) map, and Auto_rotate (on/off) attempts to
        line the map up vertically. These only work if you have openCV
        installed. otherwise a PIL version is used, which is not as good (but
        less CPU intensive). roomOutline enables the previous largest saved
        outline to be overlayed on the map (so you can see where cleaning was
        missed). This is on by default, but the alignment doesn't work so well,
        so you can turn it off.
        Returns map enabled True/False
        '''

        if not HAVE_PIL: #can't draw a map without PIL!
            return False

        if Image.__version__ < "4.1.1":
            print("WARNING: PIL version is %s, this is not the latest! you "
                  "can get bad memory leaks with old versions of PIL"
                  % Image.__version__)
            print("run: 'pip install --upgrade pillow' to fix this")

        self.drawmap = enable
        if self.drawmap:
            self.log.info("MAP: Maps Enabled")
            self.mapSize = literal_eval(mapSize)
            if len(mapSize) < 6:
                self.log.error("mapSize is required, and is of the form "
                               "(800,1500,0,0,0,0) - (x,y size, x,y dock loc,"
                               "theta1, theta2), map,roomba roatation")
                self.drawmap = False
                return False
            self.angle = self.mapSize[4]
            self.roomba_angle = self.mapSize[5]
            self.mapPath = mapPath
            if home_icon_file is None:
                self.home_icon_file = None
            else:
                self.home_icon_file = os.path.join(iconPath, home_icon_file)
            self.roomba_icon_file = os.path.join(iconPath, roomba_icon_file)
            self.roomba_error_file = os.path.join(iconPath, roomba_error_file)
            self.roomba_cancelled_file = os.path.join(iconPath, roomba_cancelled_file)
            self.roomba_battery_file = os.path.join(iconPath, roomba_battery_file)
            self.bin_full_file = os.path.join(iconPath, bin_full_file)
            self.draw_edges = draw_edges // 10000
            self.auto_rotate = auto_rotate
            if not roomOutline:
                self.log.info("MAP: Not drawing Room Outline")
            self.roomOutline = roomOutline

            self.enableMapWithText = enableMapWithText
            self.fillColor = fillColor
            self.outlineColor = outlineColor
            self.outlineWidth = outlineWidth

            self.initialise_map(roomba_size)
            return True
        return False

    def totimestamp(self, dt):
        td = dt - datetime.datetime(1970, 1, 1)
        return int(td.total_seconds())

    def dict_merge(self, dct, merge_dct):
        """ Recursive dict merge. Inspired by :meth:``dict.update()``, instead
        of updating only top-level keys, dict_merge recurses down into dicts
        nested to an arbitrary depth, updating keys. The ``merge_dct`` is
        merged into ``dct``.
        :param dct: dict onto which the merge is executed
        :param merge_dct: dct merged into dct
        :return: None
        """
        for k, v in six.iteritems(merge_dct):
            if (k in dct and isinstance(dct[k], dict)
                    and isinstance(merge_dct[k], Mapping)):
                self.dict_merge(dct[k], merge_dct[k])
            else:
                dct[k] = merge_dct[k]

    def decode_payload(self, topic, payload):
        '''
        Format json for pretty printing, return string sutiable for logging,
        and a dict of the json data
        '''
        indent = self.master_indent + 31 #number of spaces to indent json data

        try:
            # if it's json data, decode it (use OrderedDict to preserve keys
            # order), else return as is...
            json_data = json.loads(
                payload.decode("utf-8").replace(":nan", ":NaN").\
                replace(":inf", ":Infinity").replace(":-inf", ":-Infinity"),
                object_pairs_hook=OrderedDict)
            # if it's not a dictionary, probably just a number
            if not isinstance(json_data, dict):
                return json_data, dict(json_data)
            json_data_string = "\n".join((indent * " ") + i for i in \
                (json.dumps(json_data, indent = 2)).splitlines())

            formatted_data = "Decoded JSON: \n%s" % (json_data_string)

        except ValueError:
            formatted_data = payload

        if self.raw:
            formatted_data = payload

        return formatted_data, dict(json_data)

    def decode_topics(self, state, prefix=None):
        '''
        decode json data dict, and publish as individual topics to
        brokerFeedback/topic the keys are concatinated with _ to make one unique
        topic name strings are expressely converted to strings to avoid unicode
        representations
        '''
        for k, v in six.iteritems(state):
            if isinstance(v, dict):
                if prefix is None:
                    self.decode_topics(v, k)
                else:
                    self.decode_topics(v, prefix+"_"+k)
            else:
                if isinstance(v, list):
                    newlist = []
                    for i in v:
                        if isinstance(i, dict):
                            for ki, vi in six.iteritems(i):
                                newlist.append((str(ki), vi))
                        else:
                            if isinstance(i, six.string_types):
                                i = str(i)
                            newlist.append(i)
                    v = newlist
                if prefix is not None:
                    k = prefix+"_"+k
                # all data starts with this, so it's redundant
                k = k.replace("state_reported_","")
                # save variables for drawing map
                if k == "pose_theta":
                    self.co_ords["theta"] = v
                if k == "pose_point_x": #x and y are reversed...
                    self.co_ords["y"] = v
                if k == "pose_point_y":
                    self.co_ords["x"] = v
                if k == "bin_full":
                    self.bin_full = v
                if k == "cleanMissionStatus_error":
                    try:
                        self.error_message = self._ErrorMessages[v]
                    except KeyError as e:
                        self.log.warn(
                            "Error looking up Roomba error message %s" % e)
                        self.error_message = "Unknown Error number: %s" % v
                    self.publish("error_message", self.error_message)
                if k == "cleanMissionStatus_phase":
                    self.previous_cleanMissionStatus_phase = \
                        self.cleanMissionStatus_phase
                    self.cleanMissionStatus_phase = v

                self.publish(k, str(v))

        if prefix is None:
            self.update_state_machine()

    def update_state_machine(self, new_state = None):
        '''
        Roomba progresses through states (phases), current identified states
        are:
        ""              : program started up, no state yet
        "run"           : running on a Cleaning Mission
        "hmUsrDock"     : returning to Dock
        "hmMidMsn"      : need to recharge
        "hmPostMsn"     : mission completed
        "charge"        : chargeing
        "stuck"         : Roomba is stuck
        "stop"          : Stopped
        "pause"         : paused

        available states:
        states = {  "charge":"Charging",
                    "new":"New Mission",
                    "run":"Running",
                    "resume":"Running",
                    "hmMidMsn":"Recharging",
                    "recharge":"Recharging",
                    "stuck":"Stuck",
                    "hmUsrDock":"User Docking",
                    "dock":"Docking",
                    "dockend":"Docking - End Mission",
                    "cancelled":"Cancelled",
                    "stop":"Stopped",
                    "pause":"Paused",
                    "hmPostMsn":"End Mission",
                    "":None}

        Normal Sequence is "" -> charge -> run -> hmPostMsn -> charge
        Mid mission recharge is "" -> charge -> run -> hmMidMsn -> charge
                                   -> run -> hmPostMsn -> charge
        Stuck is "" -> charge -> run -> hmPostMsn -> stuck
                    -> run/charge/stop/hmUsrDock -> charge
        Start program during run is "" -> run -> hmPostMsn -> charge

        Need to identify a new mission to initialize map, and end of mission to
        finalise map.
        Assume  charge -> run = start of mission (init map)
                stuck - > charge = init map
        Assume hmPostMsn -> charge = end of mission (finalize map)
        Anything else = continue with existing map
        '''

        current_mission = self.current_state

        #if self.current_state == None: #set initial state here for debugging
        #    self.current_state = self.states["recharge"]
        #    self.show_final_map = False

        #  deal with "bin full" timeout on mission
        try:
            if (self.master_state["state"]["reported"]["cleanMissionStatus"]["mssnM"] == "none" and
                self.cleanMissionStatus_phase == "charge" and
                (self.current_state == self.states["pause"] or
                 self.current_state == self.states["recharge"])):
                self.current_state = self.states["cancelled"]
        except KeyError:
            pass

        if (self.current_state == self.states["charge"] and
                self.cleanMissionStatus_phase == "run"):
            self.current_state = self.states["new"]
        elif (self.current_state == self.states["run"] and
                self.cleanMissionStatus_phase == "hmMidMsn"):
            self.current_state = self.states["dock"]
        elif (self.current_state == self.states["dock"] and
                self.cleanMissionStatus_phase == "charge"):
            self.current_state = self.states["recharge"]
        elif (self.current_state == self.states["recharge"] and
                self.cleanMissionStatus_phase == "charge" and self.bin_full):
            self.current_state = self.states["pause"]
        elif (self.current_state == self.states["run"] and
                self.cleanMissionStatus_phase == "charge"):
            self.current_state = self.states["recharge"]
        elif (self.current_state == self.states["recharge"]
                and self.cleanMissionStatus_phase == "run"):
            self.current_state = self.states["pause"]
        elif (self.current_state == self.states["pause"]
                and self.cleanMissionStatus_phase == "charge"):
            self.current_state = self.states["pause"]
            # so that we will draw map and can update recharge time
            current_mission = None
        elif (self.current_state == self.states["charge"] and
                self.cleanMissionStatus_phase == "charge"):
            # so that we will draw map and can update charge status
            current_mission = None
        elif ((self.current_state == self.states["stop"] or
                self.current_state == self.states["pause"]) and
                self.cleanMissionStatus_phase == "hmUsrDock"):
            self.current_state = self.states["cancelled"]
        elif ((self.current_state == self.states["hmUsrDock"] or
                self.current_state == self.states["cancelled"]) and
                self.cleanMissionStatus_phase == "charge"):
            self.current_state = self.states["dockend"]
        elif (self.current_state == self.states["hmPostMsn"] and
                self.cleanMissionStatus_phase == "charge"):
            self.current_state = self.states["dockend"]
        elif (self.current_state == self.states["dockend"] and
                self.cleanMissionStatus_phase == "charge"):
            self.current_state = self.states["charge"]

        else:
            self.current_state = self.states[self.cleanMissionStatus_phase]

        if new_state is not None:
            self.current_state = self.states[new_state]
            self.log.info("set current state to: %s" % (self.current_state))

        if self.current_state != current_mission:
            self.log.info("updated state to: %s" % (self.current_state))

        self.publish("state", self.current_state)
        self.draw_map(current_mission != self.current_state)

    def make_transparent(self, image, colour=None):
        '''
        take image and make white areas transparent
        return transparent image
        '''
        image = image.convert("RGBA")
        datas = image.getdata()
        newData = []
        for item in datas:
            # white (ish)
            if item[0] >= 254 and item[1] >= 254 and item[2] >= 254:
                newData.append(self.transparent)
            else:
                if colour:
                    newData.append(colour)
                else:
                    newData.append(item)

        image.putdata(newData)
        return image

    def make_icon(self, input="./roomba.png", output="./roomba_mod.png"):
        #utility function to make roomba icon from generic roomba icon
        if not HAVE_PIL: #drawing library loaded?
            self.log.error("PIL module not loaded")
            return None
        try:
            roomba = Image.open(input).convert('RGBA')
            roomba = roomba.rotate(90, expand=False)
            roomba = self.make_transparent(roomba)
            draw_wedge = ImageDraw.Draw(roomba)
            draw_wedge.pieslice(
                [(5,0),(roomba.size[0]-5,roomba.size[1])],
                175, 185, fill="red", outline="red")
            roomba.save(output, "PNG")
            return roomba
        except Exception as e:
            self.log.error("ERROR: %s" % e)
            return None

    def load_icon(self, filename="", icon_name=None, fnt=None, size=(32,32),
                  base_icon=None):
        '''
        Load icon from file, or draw icon if file not found.
        returns icon object
        '''
        if icon_name is None:
            return None

        try:
            icon = Image.open(filename).convert('RGBA').resize(
                size,Image.ANTIALIAS)
            icon = self.make_transparent(icon)
        except IOError as e:
            self.log.warn("error loading %s: %s, using default icon instead"
                          % (icon_name,e))
            if base_icon is None:
                icon = Image.new('RGBA', size, self.transparent)
            else:
                icon = base_icon

            draw_icon = ImageDraw.Draw(icon)

            if icon_name == "roomba":
                if base_icon is None:
                    draw_icon.ellipse([(5,5),(icon.size[0]-5,icon.size[1]-5)],
                        fill="green", outline="black")
                draw_icon.pieslice([(5,5),(icon.size[0]-5,icon.size[1]-5)],
                    355, 5, fill="red", outline="red")
            elif icon_name == "stuck":
                if base_icon is None:
                    draw_icon.ellipse([(5,5),(icon.size[0]-5,icon.size[1]-5)],
                        fill="green", outline="black")
                    draw_icon.pieslice([(5,5),(icon.size[0]-5,icon.size[1]-5)],
                        175, 185, fill="red", outline="red")
                draw_icon.polygon([(
                    icon.size[0]//2,icon.size[1]), (0, 0), (0,icon.size[1])],
                    fill = 'red')
                if fnt is not None:
                    draw_icon.text((4,-4), "!", font=fnt,
                        fill=(255,255,255,255))
            elif icon_name == "cancelled":
                if base_icon is None:
                    draw_icon.ellipse([(5,5),(icon.size[0]-5,icon.size[1]-5)],
                        fill="green", outline="black")
                    draw_icon.pieslice([(5,5),(icon.size[0]-5,icon.size[1]-5)],
                        175, 185, fill="red", outline="red")
                if fnt is not None:
                    draw_icon.text((4,-4), "X", font=fnt, fill=(255,0,0,255))
            elif icon_name == "bin full":
                draw_icon.rectangle([
                    icon.size[0]-10, icon.size[1]-10,
                    icon.size[0]+10, icon.size[1]+10],
                    fill = "grey")
                if fnt is not None:
                    draw_icon.text((4,-4), "F", font=fnt,
                        fill=(255,255,255,255))
            elif icon_name == "battery":
                draw_icon.rectangle([icon.size[0]-10, icon.size[1]-10,
                    icon.size[0]+10,icon.size[1]+10], fill = "orange")
                if fnt is not None:
                    draw_icon.text((4,-4), "B", font=fnt,
                        fill=(255,255,255,255))
            elif icon_name == "home":
                draw_icon.rectangle([0,0,32,32], fill="red", outline="black")
                if fnt is not None:
                    draw_icon.text((4,-4), "D", font=fnt,
                        fill=(255,255,255,255))
            else:
                icon = None
        #rotate icon 180 degrees
        icon = icon.rotate(180-self.angle, expand=False)
        return icon

    def initialise_map(self, roomba_size):
        '''
        Initialize all map items (base maps, overlay, icons fonts etc)
        '''
        # get base image of Roomba path
        if self.base is None:
            '''try:
                self.log.info("MAP: openening existing line image")
                self.base = Image.open(
                    self.mapPath + '/' + self.roombaName + 'lines.png')\
                    .convert('RGBA')
                if self.base.size != (self.mapSize[0], self.mapSize[1]):
                    raise IOError("Image is wrong size")
            except IOError as e:
                self.base = Image.new(
                    'RGBA',
                    (self.mapSize[0], self.mapSize[1]), self.transparent)
                self.log.warn("MAP: line image problem: %s: created new image"
                              % e)

            try:
                self.log.info("MAP: openening existing problems image")
                self.roomba_problem = Image.open(
                    self.mapPath + '/'+self.roombaName + 'problems.png')\
                    .convert('RGBA')
                if self.roomba_problem.size != self.base.size:
                    raise IOError("Image is wrong size")
            except IOError as e:
                self.roomba_problem = Image.new(
                    'RGBA', self.base.size, self.transparent)
                self.log.warn("MAP: problems image problem: %s: created new "
                              "image" % e)'''

            self.base = Image.new(
                'RGBA',
                (self.mapSize[0], self.mapSize[1]), self.transparent)

            self.roomba_problem = Image.new(
                'RGBA', self.base.size, self.transparent)

            try:
                self.log.info("MAP: openening existing map no text image")
                self.previous_map_no_text = None
                self.map_no_text = Image.open(
                    self.mapPath + '/' + self.roombaName + 'map_notext.png')\
                    .convert('RGBA')
                if self.map_no_text.size != self.base.size:
                    raise IOError("Image is wrong size")
            except IOError as e:
                self.map_no_text = None
                self.log.warn("MAP: map no text image problem: %s: set to None"
                              % e)
        # save x and y center of image, for centering of final map image
        self.cx = self.base.size[0]
        self.cy = self.base.size[1]

        # get a font
        if self.fnt is None:
            try:
                self.fnt = ImageFont.truetype('FreeMono.ttf', 40)
            except IOError as e:
                self.log.warn("error loading font: %s, loading default font"
                              % e)
                self.fnt = ImageFont.load_default()

        #set dock home position
        if self.home_pos is None:
            self.home_pos = (
                self.mapSize[0] // 2 + self.mapSize[2],
                self.mapSize[1] // 2 + self.mapSize[3])
            self.log.info("MAP: home_pos: (%d,%d)"
                          % (self.home_pos[0], self.home_pos[1]))

        #get icons
        if self.roomba_icon is None:
            self.roomba_icon = self.load_icon(
                filename=self.roomba_icon_file, icon_name="roomba",
                fnt=self.fnt, size=roomba_size, base_icon=None)

        if self.roomba_error_icon is None:
            self.roomba_error_icon = self.load_icon(
                filename=self.roomba_error_file, icon_name="stuck",
                fnt=self.fnt, size=roomba_size, base_icon=self.roomba_icon)

        if self.roomba_cancelled_icon is None:
            self.roomba_cancelled_icon = self.load_icon(
                filename=self.roomba_cancelled_file, icon_name="cancelled",
                fnt=self.fnt, size=roomba_size, base_icon=self.roomba_icon)

        if self.roomba_battery_icon is None:
            self.roomba_battery_icon = self.load_icon(
                filename=self.roomba_battery_file, icon_name="battery",
                fnt=self.fnt, size=roomba_size, base_icon=self.roomba_icon)

        if self.dock_icon is None and self.home_icon_file is not None:
            self.dock_icon = self.load_icon(
                filename=self.home_icon_file, icon_name="home", fnt=self.fnt)
            self.dock_position = (
                self.home_pos[0] - self.dock_icon.size[0] // 2,
                self.home_pos[1] - self.dock_icon.size[1] // 2)

        if self.bin_full_icon is None:
            self.bin_full_icon = self.load_icon(
                filename=self.bin_full_file, icon_name="bin full",
                fnt=self.fnt, size=roomba_size, base_icon=self.roomba_icon)

        self.log.info("MAP: Initialisation complete")

    def transparent_paste(self, base_image, icon, position):
        '''
        needed because PIL pasting of transparent imges gives weird results
        '''
        image = Image.new('RGBA', self.base.size, self.transparent)
        image.paste(icon,position)
        base_image = Image.alpha_composite(base_image, image)
        return base_image

    def zero_coords(self):
        '''
        returns dictionary with default zero coords
        '''
        return {"x": 0, "y": 0, "theta": 180}

    def offset_coordinates(self, old_co_ords, new_co_ords):
        '''
        offset coordinates according to mapSize settings, with 0,0 as center
        '''
        x_y = (new_co_ords["x"] + self.mapSize[0] // 2 + self.mapSize[2],
               new_co_ords["y"] + self.mapSize[1] // 2 + self.mapSize[3])
        old_x_y = (old_co_ords["x"]+self.mapSize[0] // 2 + self.mapSize[2],
                   old_co_ords["y"]+self.mapSize[1]//2+self.mapSize[3])

        theta = int(new_co_ords["theta"] - 90 + self.roomba_angle)
        while theta > 359: theta = 360 - theta
        while theta < 0: theta = 360 + theta

        return old_x_y, x_y, theta

    def get_roomba_pos(self, x_y):
        '''
        calculate roomba position as list
        '''
        return [x_y[0] - self.roomba_icon.size[0] // 2,
                x_y[1] - self.roomba_icon.size[1] // 2,
                x_y[0] + self.roomba_icon.size[0] // 2,
                x_y[1] + self.roomba_icon.size[1] // 2]

    def draw_vacuum_lines(self, image, old_x_y, x_y, theta):
        '''
        draw lines on image from old_x_y to x_y reepresenting vacuum coverage,
        taking into account angle theta (roomba angle).
        '''
        lines = ImageDraw.Draw(image)
        if x_y != old_x_y:
            self.log.info("MAP: drawing line: %s, %s" % (old_x_y, x_y))
            lines.line([old_x_y, x_y], fill=self.fillColor,
                       width=self.roomba_icon.size[0] // 2)
        #draw circle over roomba vacuum area to give smooth edges.
        arcbox = [x_y[0]-self.roomba_icon.size[0] // 4,
                  x_y[1]-self.roomba_icon.size[0] // 4,
                  x_y[0]+self.roomba_icon.size[0] // 4,
                  x_y[1]+self.roomba_icon.size[0] // 4]
        lines.ellipse(arcbox, fill=self.fillColor)

    def draw_text(self, image, display_text, fnt, pos=(0,0),
                  colour=(0,0,255,255), rotate=False):
        #draw text - (WARNING old versions of PIL have huge memory leak here!)
        if display_text is None: return
        self.log.info("MAP: writing text: pos: %s, text: %s"
                      % (pos, display_text))
        if rotate:
            txt = Image.new('RGBA', (fnt.getsize(display_text)),
                            self.transparent)
            text = ImageDraw.Draw(txt)
            # draw text rotated 180 degrees...
            text.text((0,0), display_text, font=fnt, fill=colour)
            image.paste(txt.rotate(180-self.angle, expand=True), pos)
        else:
            text = ImageDraw.Draw(image)
            text.text(pos, display_text, font=fnt, fill=colour)

    def draw_map(self, force_redraw=False):
        '''
        Draw map of Roomba cleaning progress
        '''
        if ((self.co_ords != self.previous_co_ords or
             self.cleanMissionStatus_phase !=
             self.previous_cleanMissionStatus_phase)
             or force_redraw) and self.drawmap:
            self.render_map(self.co_ords, self.previous_co_ords)
            self.previous_co_ords = self.co_ords.copy()
            self.previous_cleanMissionStatus_phase = \
                self.cleanMissionStatus_phase

    def render_map(self, new_co_ords, old_co_ords):
        '''
        draw map
        '''
        draw_final = False
        stuck = False
        cancelled = False
        bin_full = False
        battery_low = False

        # program just started, and we don't have phase yet.
        if self.current_state is None:
            return

        if self.show_final_map == False:
            self.log.info("MAP: received: new_co_ords: %s old_co_ords: %s "
                          "phase: %s, state: %s" % (
                          new_co_ords, old_co_ords,
                          self.cleanMissionStatus_phase, self.current_state))

        if  self.current_state == self.states["charge"]:
            self.log.info("MAP: ignoring new co-ords in charge phase")
            new_co_ords = old_co_ords = self.zero_coords()
            self.display_text = "Charging: Battery: " + \
                str(self.master_state["state"]["reported"]["batPct"]) + "%"
            if self.bin_full:
                self.display_text = "Bin Full," + \
                    self.display_text.replace("Charging", "Not Ready")
            if (self.last_completed_time is None or time.time() -
                    self.last_completed_time > 3600):
                self.save_text_and_map_on_whitebg(self.map_no_text)
            draw_final = True

        elif self.current_state == self.states["recharge"]:
            self.log.info("MAP: ignoring new co-ords in recharge phase")
            new_co_ords = old_co_ords = self.zero_coords()
            self.display_text = "Recharging:" + " Time: " + \
                str(self.master_state["state"]["reported"]["cleanMissionStatus"]["rechrgM"]) + "m"
            if self.bin_full:
                self.display_text = "Bin Full," + self.display_text
            self.save_text_and_map_on_whitebg(self.map_no_text)

        elif self.current_state == self.states["pause"]:
            self.log.info("MAP: ignoring new co-ords in pause phase")
            new_co_ords = old_co_ords
            self.display_text = "Paused: " + \
                str(self.master_state["state"]["reported"]["cleanMissionStatus"]["mssnM"]) + \
                "m, Bat: "+ str(self.master_state["state"]["reported"]["batPct"]) + \
                "%"
            if self.bin_full:
                self.display_text = "Bin Full," + self.display_text
                # assume roomba is docked...
                new_co_ords = old_co_ords = self.zero_coords()
            self.save_text_and_map_on_whitebg(self.map_no_text)

        elif self.current_state == self.states["hmPostMsn"]:
            self.display_text = "Completed: " + \
                time.strftime("%a %b %d %H:%M:%S")
            self.log.info("MAP: end of mission")

        elif self.current_state == self.states["dockend"]:
            self.log.info("MAP: mission completed: ignoring new co-ords in "
                          "docking phase")
            new_co_ords = old_co_ords = self.zero_coords()
            self.draw_final_map(True)
            draw_final = True

        elif (self.current_state == self.states["run"] or
              self.current_state == self.states["stop"] or
              self.current_state == self.states["pause"]):
            if self.current_state == self.states["run"]:
                self.display_text = self.states["run"] + " Time: " + \
                    str(self.master_state["state"]["reported"]["cleanMissionStatus"]["mssnM"]) + \
                    "m, Bat: "+ str(self.master_state["state"]["reported"]["batPct"]) + \
                    "%"
            else:
                self.display_text = None
            self.show_final_map = False

        elif self.current_state == self.states["new"]:
            self.angle = self.mapSize[4]    #reset angle
            self.base = Image.new('RGBA', self.base.size, self.transparent)
            # overlay for roomba problem position
            self.roomba_problem = Image.new('RGBA', self.base.size,
                                            self.transparent)
            self.show_final_map = False
            self.display_text = None
            self.log.info("MAP: created new image at start of new run")

        elif self.current_state == self.states["stuck"]:
            self.display_text = "STUCK!: " + time.strftime("%a %b %d %H:%M:%S")
            self.draw_final_map(True)
            draw_final = True
            stuck = True

        elif self.current_state == self.states["cancelled"]:
            self.display_text = "Cancelled: " + \
                time.strftime("%a %b %d %H:%M:%S")
            cancelled = True

        elif self.current_state == self.states["dock"]:
            self.display_text = "Docking"
            if self.bin_full:
                self.display_text = "Bin Full," + self.display_text
                bin_full = True
            else:
                self.display_text = "Battery low: " + \
                    str(self.master_state["state"]["reported"]["batPct"]) + \
                    "%, " + self.display_text
                battery_low = True

        else:
            self.log.warn("MAP: no special handling for state: %s"
                          % self.current_state)

        if self.base is None:
            self.log.warn("MAP: no image, exiting...")
            return

        if self.display_text is None:
            self.display_text = self.current_state

        if self.show_final_map: #just display final map - not live
            self.log.debug("MAP: not updating map - Roomba not running")
            return

        if self.debug:
            # debug final map (careful, uses a lot of CPU power!)
            self.draw_final_map()

        #calculate co-ordinates, with 0,0 as center
        old_x_y, x_y, theta = self.offset_coordinates(old_co_ords, new_co_ords)
        roomba_pos = self.get_roomba_pos(x_y)

        self.log.info("MAP: old x,y: %s new x,y: %s theta: %s roomba pos: %s" %
                      (old_x_y, x_y, theta, roomba_pos))

        #draw lines
        self.draw_vacuum_lines(self.base, old_x_y, x_y, theta)

        # make a blank image for the text and Roomba overlay, initialized to
        # transparent text color
        roomba_sprite = Image.new('RGBA', self.base.size, self.transparent)

        #draw roomba
        self.log.info("MAP: drawing roomba: pos: %s, theta: %s"
                      % (roomba_pos, theta))

        has_problems = False

        if stuck:
            self.log.info("MAP: Drawing stuck Roomba")
            self.roomba_problem.paste(self.roomba_error_icon,roomba_pos)
            has_problems = True
        if cancelled:
            self.log.info("MAP: Drawing cancelled Roomba")
            self.roomba_problem.paste(self.roomba_cancelled_icon,roomba_pos)
            has_problems = True
        if bin_full:
            self.log.info("MAP: Drawing full bin")
            self.roomba_problem.paste(self.bin_full_icon,roomba_pos)
            has_problems = True
        if battery_low:
            self.log.info("MAP: Drawing low battery Roomba")
            self.roomba_problem.paste(self.roomba_battery_icon,roomba_pos)
            has_problems = True

        roomba_sprite = self.transparent_paste(
            roomba_sprite,
            self.roomba_icon.rotate(theta, expand=False), roomba_pos)

        # paste dock over roomba_sprite
        if self.dock_icon is not None:
            roomba_sprite = self.transparent_paste(
                roomba_sprite, self.dock_icon, self.dock_position)

        '''# save base lines
        self.base.save(self.mapPath + '/' + self.roombaName + 'lines.png',
                       "PNG")

        # save problem overlay
        self.roomba_problem.save(self.mapPath + '/' + self.roombaName + \
                                'problems.png', "PNG")'''

        if self.roomOutline or self.auto_rotate:
            # draw room outline (saving results if this is a final map) update
            # x,y and angle if auto_rotate
            self.draw_room_outline(draw_final)
        # merge room outline into base
        if self.roomOutline:
            #if we want to draw the room outline
            out = Image.alpha_composite(self.base, self.room_outline)
        else:
            out = self.base
        #merge roomba lines (trail) with base
        out = Image.alpha_composite(out, roomba_sprite)

        if has_problems:
            #merge problem location for roomba into out
            out = Image.alpha_composite(out, self.roomba_problem)

        if draw_final and self.auto_rotate:
            #translate image to center it if auto_rotate is on
            self.log.info("MAP: calculation of center: (%d,%d), "
                          "translating final map to center it, x:%d, y:%d "
                          "deg: %.2f" % (
                            self.cx, self.cy, self.cx - out.size[0] // 2,
                            self.cy - out.size[1] // 2,
                            self.angle))
            out = out.transform(
                out.size, Image.AFFINE,
                (1, 0, self.cx-out.size[0] // 2,
                 0, 1, self.cy-out.size[1] // 2))
        # map is upside down, so rotate 180 degrees, and size to fit (NW 12/4/2018 fixed bug causing distorted maps when rotation is not 0)
        #out_rotated = out.rotate(180 + self.angle, expand=True).resize(self.base.size) #old version
        out_rotated = out.rotate(180, expand=False)
        # save composite image
        self.save_text_and_map_on_whitebg(out_rotated)
        if draw_final:
            self.show_final_map = True  # prevent re-drawing of map until reset

    def save_text_and_map_on_whitebg(self, map):
        # if no map or nothing changed
        if map is None or (map == self.previous_map_no_text and
                           self.previous_display_text == self.display_text):
            return
        self.map_no_text = map
        self.previous_map_no_text = self.map_no_text
        self.previous_display_text = self.display_text
        self.map_no_text.save(self.mapPath + '/' + self.roombaName +
                              'map_notext.png', "PNG")
        
        if( self.enableMapWithText ):
            final = Image.new('RGBA', self.base.size, (255,255,255,255))    # white
            # paste onto a white background, so it's easy to see
            final = Image.alpha_composite(final, map)
            final = final.rotate(self.angle, expand=True) #(NW 12/4/2018 fixed bug causing distorted maps when rotation is not 0 - moved rotate to here)
            # draw text
            self.draw_text(final, self.display_text, self.fnt)
            final.save(self.mapPath + '/'+self.roombaName + '_map.png', "PNG")
            # try to avoid other programs reading file while writing it,
            # rename should be atomic.
            os.rename(self.mapPath + '/' + self.roombaName + '_map.png',
                self.mapPath + '/' + self.roombaName + 'map.png')

    def ScaleRotateTranslate(self, image, angle, center=None, new_center=None,
                             scale=None, expand=False):
        '''
        experimental - not used yet
        '''
        if center is None:
            return image.rotate(angle, expand)
        angle = -angle / 180.0 * math.pi
        nx, ny = x, y = center
        if new_center != center:
            (nx, ny) = new_center
        sx = sy = 1.0
        if scale:
            (sx, sy) = scale
        cosine = math.cos(angle)
        sine = math.sin(angle)
        a = cosine / sx
        b = sine / sx
        c = x - nx * a - ny * b
        d = -sine / sy
        e = cosine / sy
        f = y - nx * d - ny * e
        return image.transform(image.size, Image.AFFINE,
                               (a,b,c,d,e,f), resample=Image.BICUBIC)

    def match_outlines(self, orig_image, skewed_image):
        orig_image = np.array(orig_image)
        skewed_image = np.array(skewed_image)
        try:
            surf = cv2.xfeatures2d.SURF_create(400)
        except Exception:
            surf = cv2.SIFT(400)
        kp1, des1 = surf.detectAndCompute(orig_image, None)
        kp2, des2 = surf.detectAndCompute(skewed_image, None)

        FLANN_INDEX_KDTREE = 0
        index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
        search_params = dict(checks=50)
        flann = cv2.FlannBasedMatcher(index_params, search_params)
        matches = flann.knnMatch(des1, des2, k=2)

        # store all the good matches as per Lowe's ratio test.
        good = []
        for m, n in matches:
            if m.distance < 0.7 * n.distance:
                good.append(m)

        MIN_MATCH_COUNT = 10
        if len(good) > MIN_MATCH_COUNT:
            src_pts = np.float32([kp1[m.queryIdx].pt for m in good
                                  ]).reshape(-1, 1, 2)
            dst_pts = np.float32([kp2[m.trainIdx].pt for m in good
                                  ]).reshape(-1, 1, 2)

            M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)

            # see https://ch.mathworks.com/help/images/examples/find-image-rotation-and-scale-using-automated-feature-matching.html for details
            ss = M[0, 1]
            sc = M[0, 0]
            scaleRecovered = math.sqrt(ss * ss + sc * sc)
            thetaRecovered = math.atan2(ss, sc) * 180 / math.pi
            self.log.info("MAP: Calculated scale difference: %.2f, "
                          "Calculated rotation difference: %.2f" %
                          (scaleRecovered, thetaRecovered))

            #deskew image
            im_out = cv2.warpPerspective(skewed_image, np.linalg.inv(M),
                (orig_image.shape[1], orig_image.shape[0]))
            return im_out

        else:
            self.log.warn("MAP: Not  enough  matches are found   -   %d/%d"
                          % (len(good), MIN_MATCH_COUNT))
            return skewed_image

    def draw_room_outline(self, overwrite=False):
        '''
        draw room outline
        '''
        self.log.info("MAP: checking room outline")
        if HAVE_CV2:
            if self.room_outline_contour is None or overwrite:
                try:
                    self.room_outline_contour = np.load(
                        self.mapPath + '/' + self.roombaName + 'room.npy')
                except IOError as e:
                    self.log.warn("Unable to load room outline: %s, setting "
                                  "to 0" % e)
                    self.room_outline_contour = np.array(
                        [(0,0),(0,0),(0,0),(0,0)], dtype=np.int)

                try:
                    self.log.info("MAP: openening existing room outline image")
                    self.room_outline = Image.open(
                        self.mapPath + '/' + self.roombaName + 'room.png').\
                        convert('RGBA')
                    if self.room_outline.size != self.base.size:
                        raise IOError("Image is wrong size")
                except IOError as e:
                    self.room_outline = Image.new(
                        'RGBA', self.base.size, self.transparent)
                    self.log.warn("MAP: room outline image problem: %s: "
                                  "set to New" % e)

            room_outline_area = cv2.contourArea(self.room_outline_contour)
            # edgedata = cv2.add(
            #     np.array(self.base.convert('L'), dtype=np.uint8),
            #     np.array(self.room_outline.convert('L'), dtype=np.uint8))
            edgedata = np.array(self.base.convert('L'))
            # find external contour
            _, contours, _ = self.findContours(
                edgedata,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
            if contours[0] is None: return
            if len(contours[0]) < 5: return
            max_area = cv2.contourArea(contours[0])
            # experimental shape matching
            # note cv2.cv.CV_CONTOURS_MATCH_I1 does not exist in CV 3.0,
            # so just use 1
            match = cv2.matchShapes(
                self.room_outline_contour,contours[0],1,0.0)
            self.log.info("MAP: perimeter/outline match is: %.4f" % match)
            # if match is less than 0.35 - shapes are similar (but if it's 0 -
            # then they are the same shape..) try auto rotating map to fit.
            if match < 0.35 and match > 0:
                #self.match_outlines(self.room_outline, self.base)
                pass
            if max_area > room_outline_area:
                self.log.info("MAP: found new outline perimiter")
                self.room_outline_contour = contours[0]
                perimeter = cv2.arcLength(self.room_outline_contour,True)
                outline = Image.new('RGBA',self.base.size,self.transparent)
                edgeimage = np.array(outline)   # make blank RGBA image array
                # self.draw_edges is the max deviation from a line (set to 0.3%)
                # you can fiddle with this
                approx = cv2.approxPolyDP(
                    self.room_outline_contour,
                    self.draw_edges * perimeter,
                    True)
                # outline with grey, width 1
                cv2.drawContours(edgeimage,[approx] , -1, self.outlineColor, self.outlineWidth)
                self.room_outline = Image.fromarray(edgeimage)

        else:
            if self.room_outline is None or overwrite:
                try:
                    self.log.info("MAP: openening existing room outline image")
                    self.room_outline = Image.open(
                        self.mapPath + '/' + self.roombaName + 'room.png').\
                        convert('RGBA')
                    if self.room_outline.size != self.base.size:
                        raise IOError("Image is wrong size")
                except IOError as e:
                    self.room_outline = Image.new(
                        'RGBA', self.base.size, self.transparent)
                    self.log.warn("MAP: room outline image problem: %s: "
                                  "set to New" % e)
            edges = ImageOps.invert(self.room_outline.convert('L'))
            edges.paste(self.base)
            edges = edges.convert('L').filter(ImageFilter.SMOOTH_MORE)
            edges = ImageOps.invert(edges.filter(ImageFilter.FIND_EDGES))
            self.room_outline = self.make_transparent(edges, (0, 0, 0, 255))

        if overwrite or self.debug:
            # save room outline
            self.room_outline.save(
                self.mapPath+'/'+self.roombaName+'room.png', "PNG")
            if HAVE_CV2:
                # save room outline contour as numpy array
                np.save(self.mapPath + '/' + self.roombaName + 'room.npy',
                        self.room_outline_contour)
            if self.auto_rotate:
                # update outline centre
                self.get_image_parameters(
                    image=self.room_outline, contour=self.room_outline_contour,
                    final=overwrite)
                self.log.info("MAP: calculation of center: (%d,%d), "
                              "translating room outline to center it, "
                              "x:%d, y:%d deg: %.2f" % (
                              self.cx, self.cy,
                              self.cx - self.base.size[0] // 2,
                              self.cy - self.base.size[1] // 2,
                              self.angle))
                # center room outline, same as map.
                self.room_outline = self.room_outline.transform(
                    self.base.size, Image.AFFINE,
                    (1, 0, self.cx - self.base.size[0] // 2,
                     0, 1, self.cy-self.base.size[1]//2))
            self.log.info("MAP: Wrote new room outline files")

    def PIL_get_image_parameters(self, image=None, start=90, end = 0, step=-1,
                                 recursion=0):
        '''
        updates angle of image, and centre using PIL.
        NOTE: this assumes the floorplan is rectangular! if you live in a
        lighthouse, the angle will not be valid!
        input is PIL image
        '''
        if image is not None and HAVE_PIL:
            imbw = image.convert('L')
            max_area = self.base.size[0] * self.base.size[1]
            x_y = (self.base.size[0] // 2, self.base.size[1] // 2)
            angle = self.angle
            div_by_10 = False
            if step >=10 or step <=-10:
                step /= 10
                div_by_10 = True
            for try_angle in range(start, end, step):
                if div_by_10:
                    try_angle /= 10.0
                #rotate image and resize to fit
                im = imbw.rotate(try_angle, expand=True)
                box = im.getbbox()
                if box is not None:
                    area = (box[2]-box[0]) * (box[3]-box[1])
                    if area < max_area:
                        angle = try_angle
                        x_y = ((box[2] - box[0]) // 2 + box[0],
                               (box[3] - box[1]) // 2 + box[1])
                        max_area = area

        if recursion >= 1:
            return x_y, angle

        x_y, angle = self.PIL_get_image_parameters(
            image,
            (angle + 1) * 10,
            (angle - 1) * 10, -10,
            recursion + 1)

        # self.log.info("MAP: PIL: image center: "
        #               "x:%d, y:%d, angle %.2f" % (x_y[0], x_y[1], angle))
        return x_y, angle

    def get_image_parameters(self, image=None, contour=None, final=False):
        '''
        updates angle of image, and centre using cv2 or PIL.
        NOTE: this assumes the floorplan is rectangular! if you live in a
        lighthouse, the angle will not be valid!
        input is cv2 contour or PIL image
        routines find the minnimum area rectangle that fits the image outline
        '''
        if contour is not None and HAVE_CV2:
            # find minnimum area rectangle that fits
            # returns (x,y), (width, height), theta - where (x,y) is the center
            x_y,l_w,angle = cv2.minAreaRect(contour)

        elif image is not None and HAVE_PIL:
            x_y, angle = self.PIL_get_image_parameters(image)

        else:
            return

        if angle < self.angle - 45:
            angle += 90
        if angle > 45-self.angle:
            angle -= 90

        if final:
            self.cx = x_y[0]
            self.cy = x_y[1]
            self.angle = angle
        self.log.info("MAP: image center: x:%d, y:%d, angle %.2f" %
                      (x_y[0], x_y[1], angle))

    def angle_between(self, p1, p2):
        '''
        clockwise angle between two points in degrees
        '''
        if HAVE_CV2:
            ang1 = np.arctan2(*p1[::-1])
            ang2 = np.arctan2(*p2[::-1])
            return np.rad2deg((ang1 - ang2) % (2 * np.pi))
        else:
            side1=math.sqrt(((p1[0] - p2[0]) ** 2))
            side2=math.sqrt(((p1[1] - p2[1]) ** 2))
            return math.degrees(math.atan(side2/side1))

    def findContours(self,image,mode,method):
        '''
        Version independent find contours routine. Works with OpenCV 2 or 3 or 4.
        Returns modified image (with contours applied), contours list, hierarchy
        '''
        ver = int(cv2.__version__.split(".")[0])
        im = image.copy()
        if ver == 2 or ver == 4: #NW fix for OpenCV V4 21st Dec 2018
            contours, hierarchy = cv2.findContours(im,mode,method)
            return im, contours, hierarchy
        else:
            im_cont, contours, hierarchy = cv2.findContours(im,mode,method)
            return im_cont, contours, hierarchy

    def draw_final_map(self, overwrite=False):
        '''
        draw map with outlines at end of mission. Called when mission has
        finished and Roomba has docked
        '''
        merge = Image.new('RGBA',self.base.size,self.transparent)
        if HAVE_CV2:
            # NOTE: this is CPU intensive!
            edgedata = np.array(self.base.convert('L'), dtype=np.uint8)
            # find all contours
            _, contours, _ = self.findContours(
                edgedata,cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)
            # zero edge data for later use
            edgedata.fill(0)
            max_perimeter = 0
            max_contour = None
            for cnt in contours:
                perimeter = cv2.arcLength(cnt,True)
                if perimeter >= max_perimeter:
                    max_contour = cnt   # get the contour with maximum length
                    max_perimeter = perimeter
            if max_contour is None: return
            if len(max_contour) < 5: return
            try:
                contours.remove(max_contour)    # remove max contour from list
            except ValueError:
                self.log.warn("MAP: unable to remove contour")
                pass

            mask = np.full(edgedata.shape, 255, dtype=np.uint8) # white
            # create mask (of other contours) in black
            cv2.drawContours(mask,contours, -1, 0, -1)

            # self.draw_edges is the max deviation from a line
            # you can fiddle with this in enable_map
            approx = cv2.approxPolyDP(max_contour,
                self.draw_edges * max_perimeter,True)

            bgimage = np.array(merge)   # make blank RGBA image array
            # draw contour and fill with "lawngreen"
            cv2.drawContours(bgimage,[approx] , -1, (124, 252, 0, 255), -1)
            # mask image with internal contours
            bgimage = cv2.bitwise_and(bgimage,bgimage,mask = mask)
            # not dure if you really need this - uncomment if you want the
            # area outlined.
            # draw longest contour aproximated to lines (in black), width 1
            cv2.drawContours(edgedata,[approx] , -1, (255), 1)

            outline = Image.fromarray(edgedata) # outline
            base = Image.fromarray(bgimage)   # filled background image
        else:
            base = self.base.filter(ImageFilter.SMOOTH_MORE)
            # draw edges at end of mission
            outline = base.convert('L').filter(ImageFilter.FIND_EDGES)
            # outline = ImageChops.subtract(
            #     base.convert('L').filter(ImageFilter.EDGE_ENHANCE),
            #     base.convert('L'))

        edges = ImageOps.invert(outline)
        edges = self.make_transparent(edges, (0, 0, 0, 255))
        if self.debug:
            edges.save(self.mapPath+'/'+self.roombaName+'edges.png', "PNG")
        merge = Image.alpha_composite(merge,base)
        merge = Image.alpha_composite(merge,edges)
        if overwrite:
            self.log.info("MAP: Drawing final map")
            self.last_completed_time = time.time()
            self.base=merge

        if self.debug:
            merge_rotated = merge.rotate(180+self.angle, expand=True)
            merge_rotated.save(
                self.mapPath+'/'+self.roombaName+'final_map.png', "PNG")