""" Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy of the License is located at http://aws.amazon.com/apache2.0 or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ------------------------------------------------------------------------------ This program generates a set of "vehicles" in IOT, and "moves" them a given number of times. Each move is to a randomly generated new location, and causes an update to the IoT shadow. If a "desired location" is set in the shadow, we move directly towards that point. The program optionally (depending on parameters): 1. Optionally creates a set of 'n' things in AWS IoT (--new -v n). Coded limit of 250 things created. If --new is not chosen, we query IoT for 'n' things (-v n) with the given (or default) companyId 2. Moves them 'n' times (-m n) 3. Sleeps between each move 'n' seconds (-s n) 4. Optionally deletes the things (--deletefleet') (fyi) Example topic name for our thing's updates: thingTopic = '$aws/things/' + thingNm + '/shadow/update' Example run: python iotfleet.py --new --vehicles 50 --move 200 --companyId abc --deletefleet OR short version: python iotfleet.py --new -v 50 -m 200 -c abc --deletefleet parser.add_argument("-s", "--sleeptime", required = False, type = int, default = 5, help = "seconds to sleep between moving the vehicles") parser.add_argument("-m", "--move", required = False, type = int, default = 5, help = "number of times to move the vehicles") parser.add_argument("-v", "--vehicles", required = False, type = int, default = 10, help = "number of vehicles to create") parser.add_argument("-p", "--prefix", required = False, type = str, default = 'iotfleet_', help = "prefix for thing names") parser.add_argument("-c", "--companyid", required = False, type = str, default = 'thc', help = "companyId (thing attribute)") parser.add_argument("-l", "--logfile", required = False, type = str, help = "full path of log file; by default located in CWD") parser.add_argument("-t", "--test", action='store_true', help = "for testing: turn off the calls to the location service") parser.add_argument("--new", action='store_true', help = "create the fleet") parser.add_argument("--deletefleet", action='store_true', help = "delete the fleet") parser.add_argument('--version', action='version', version='%(prog)s 1.0') Prerequisites: - install/upgrade boto3 to a very recent version: sudo pip install boto3 --upgrade - install geopy: sudo pip install geopy """ from __future__ import print_function import json import time import random import math import sys import codecs import logging import argparse # TO DO: code for botocore.exception.ClientError import boto3 from geopy.geocoders import Nominatim from geopy.distance import vincenty #from pprint import pprint #pprint.PrettyPrinter(indent=4).pprint(response) # Force output to utf-8 # Because I'm getting some addresses with French spellings, and the system encoding is defaulting to None reload(sys) sys.setdefaultencoding('utf-8') # log level - see module logging LOG_LEVEL = logging.INFO # Since we create things / generate loads of requests, we'll limit the number of things to this vehicleLimit = 250 # http://www.justtrails.com/nav-skills/32-points-compass/ directions = {"N":0, "NE":45, "E":90, "SE": 135, "S":180, "SW": 225, "W":270, "NW":315 } # TO FIX: generalize dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] #dirs = directions.keys() # Define the area within which we'll move our vehicles # http://www.bdcc.co.uk/Gmaps/ll_grat_v3_demo.htm gets you lat/long grid # (but unfortunately not with state boundaries # U.S. mainland is (very roughly): lat 35:50, long -120:-80 # Portland, OR: 45.661251, -122.834334; 45.404400, -122.430586 # Voodoo Donuts: 45.522655, -122.673070 #geogrid = (50,-80,35,-120) #topx, topy, bottomx, bottomy geogrid = (45.661251, -122.834334, 45.404400, -122.430586) maxSpeed = 90 # miles to kilometers conversion factor km2miles = 0.621371 iot = boto3.client('iot') iotdata = boto3.client('iot-data') def get_args(): """ Parse command line arguments and populate args object. The args object is passed to functions as argument Returns: object (ArgumentParser): arguments and configuration settings """ parser = argparse.ArgumentParser(description = 'Generate a set of "vehicles" in IOT, and move them a given number of times.') parser.add_argument("-s", "--sleeptime", required = False, type = int, default = 5, help = "seconds to sleep between moving the vehicles") parser.add_argument("-m", "--move", required = False, type = int, default = 5, help = "number of times to move the vehicles") parser.add_argument("-v", "--vehicles", required = False, type = int, default = 10, help = "number of vehicles to create") parser.add_argument("-p", "--prefix", required = False, type = str, default = 'iotfleet_', help = "prefix for thing names") parser.add_argument("-c", "--companyid", required = False, type = str, default = 'thc', help = "companyId (thing attribute)") parser.add_argument("-l", "--logfile", required = False, type = str, help = "full path of log file; by default located in CWD") parser.add_argument("-t", "--test", action='store_true', help = "for testing: turn off the calls to the location service") parser.add_argument("--new", action='store_true', help = "create the fleet") parser.add_argument("--deletefleet", action='store_true', help = "delete the fleet") parser.add_argument('--version', action='version', version='%(prog)s 1.0') # parser.add_argument("-c", "--configfile", required = False, type = str, default = None, help = "full path of configuration file; if other arguments are provided on the command line, the CLI values will override the config file provided values.") if len(sys.argv) == 1: parser.error('No arguments provided. Exiting for safety.') logging.critical("No arguments provided. Exiting for safety.") sys.exit() args = parser.parse_args() args.prog = parser.prog if not args.logfile: args.logfile = args.prog.split('.')[0] + '.log' print("Log entries are being output to " + args.logfile) # TO DO: Generalize. Allow some other attribue name. args.attname = 'companyId' args.attvalue = args.companyid return args def get_direction(brng): """ Convert a bearing (in radians) to a compass direction (string) [HACK] Valid directions are given in the dictionary "dirs". Note: Modifying the directions used or number of them will require a rewrite. Parameter: brng A bearing (in radians) Returns: string the closest compass direction (e.g., N, NW) to that bearing """ dirctn = math.degrees(brng) ndir = (dirctn + 360) % 360 dex = ndir - 22.5; if (dex < 0): dex += 360 index = int(dex / 45)+1 if index > 7: index = 0 #print(brng, dirctn, ndir,dex,index,dirs[index]) return(dirs[index]) def moveTo(curLoc,dist,dir,dolo=False): """ Move 'dist' from a current lat & long, in the given 'dir'. Return lat/lon and optionally address of new location (if dolo) With ack & thanks to ? on StackOverflow for sample code. http://gistools.igismap.com/bearing to check (add 360, if < 0) Parameters: curLoc current location: {"lat": float, "lon": float} dist distance you want to move (in miles) dir compass direction in which to move dolo boolean: turn on (do)/off the calls to the location service Returns: ({"lat": new_lat, "lon": new_lon},address_string) """ # TO DO: Is there a better/simpler way, e.g. in geopy? curLat = float(curLoc["lat"]) curLong = float(curLoc["lon"]) #print(str(curLat),str(curLong),str(dist),str(dir),str(dolo)) R = 6378.1 #Radius of the Earth degrees = directions[dir] brng = math.pi * degrees / 180 #Bearing is degrees converted to radians d = dist / km2miles #Distance in km -- TO DO MILES # Test: lat1: 52.20472; lon1: 0.14056; dist: 15 km, brng = 1.57 90 degress #lat2 52.20444 - the lat result I'm hoping for #lon2 0.36056 - the long result I'm hoping for. lat1 = math.radians(curLat) #Current lat point converted to radians lon1 = math.radians(curLong) #Current long point converted to radians lat2 = math.asin( math.sin(lat1)*math.cos(d/R) + math.cos(lat1)*math.sin(d/R)*math.cos(brng)) lon2 = lon1 + math.atan2(math.sin(brng)*math.sin(d/R)*math.cos(lat1), math.cos(d/R)-math.sin(lat1)*math.sin(lat2)) lat2 = math.degrees(lat2) lon2 = math.degrees(lon2) address = "" if not dolo: try: loc = geolocator.reverse(str(lat2) + "," + str(lon2), timeout=10) address = loc.address except: logging.info("Error in geolocation: no lo for "+ str(lat2) + ", " + str(lon2)) #except GeocoderTimedOut as e: # print("Error: geocode failed on input %s with message %s"%(str(lat2) + "," + str(lon2), e.msg)) #print(str(curLat),str(curLong),str(dist),str(dir),str(lat2),str(lon2),str(address),str(dolo)) return ({"lat": lat2, "lon": lon2},address) def calcDir(nowAt,goTo): """ Calculate the distance and direction from nowAt to goTo. With ack and thanks to ? at StackOverflow for sample code & calcs. Parameters: nowAt here: {"lat": lat2, "lon": lon2} goTo there: {"lat": lat2, "lon": lon2} Returns: (distance, dir) tuple: miles from here to there (float), compass direction (string) """ try: lat1 = math.radians(float(nowAt["lat"])) long1 = math.radians(float(nowAt["lon"])) lat2 = math.radians(float(goTo["lat"])) long2 = math.radians(float(goTo["lon"])) # in radians. The result is between -pi and pi. bearing = math.atan2(math.sin(long2-long1)*math.cos(lat2), \ math.cos(lat1)*math.sin(lat2)-math.sin(lat1)*math.cos(lat2)*math.cos(long2-long1)) newHead = get_direction(bearing) go = (goTo["lat"], goTo["lon"]) now = (nowAt["lat"], nowAt["lon"]) distance = vincenty(go, now).miles #print("From ", str(now), " to ", str(go), " is ", str(distance), str(bearing), newHead, '\n' ) #newHead = 'S' # TO FIX return(distance, newHead) except: return(None,None) def create_fleet(howMany, thingPrefix, attName="companyId", attVal="thc"): """ Create a fleet of howMany vehicles in AWS IoT. Give each of them a name that starts with thingPrefix, and an attribue and value. Add a vehicleId with a generated numeric string. Put them in a random location (within a given grid), with a random set of starting conditions. Parameters: howMany integer; number of vehicles to create thingPrefix string attName, attVal: strings; passed to IoT, to let you identify this set of things. "Side Effect" (always loved that phrase): creates things in IoT service. Returns: fleet array of JSON objects, similar to the IoT shadow for each thing """ global geogrid (topx, topy, bottomx, bottomy) = geogrid for f in range(0,howMany): thingNm = thingPrefix + str(100 + f) # TO DO: better naming std? thingIs = iot.create_thing( thingName=thingNm, attributePayload={ # TO DO: generalize 'attributes': { attName: attVal, 'vehicleId': str(100 + f) } } ) locLat = random.uniform(bottomx,topx) locLong = random.uniform(bottomy,topy) # Add a description for the address, if possible address = "" if not args.test: try: loc = geolocator.reverse(str(locLat) + "," + str(locLong), timeout=10) address = loc.address except: pass # Assume: ignition = 0 means truck is off. Set speed to 0. ignition = random.randint(0,1) speed = 0 if ignition == 1: speed = random.randint(0,maxSpeed) heading = dirs[random.randint(0,len(dirs)-1)] vehicle = { "reported": { "location":{ "lat": str(locLat), "lon": str(locLong) }, "address": address, "ignition": str(ignition), "speed": str(speed), "heading": heading} } #stateIs = locString + ',"ignition":"'+str(ignition)+'","speed":"'+str(speed)+'","heading":"' + heading + '"' stateString = '{ "state": ' + json.dumps(vehicle) + ' }' updt = iotdata.update_thing_shadow( thingName=thingNm, payload=stateString ) vehicle["thingName"] = thingNm fleet.append( vehicle ) #print("Initialized "+thingNm + json.dumps(updt['payload'].read())) logging.info('Initialized vehicle # ' + str(f) + ", "+ str(thingNm) + ' at ' + str(address) + ' going ' + str(speed) + ' mph.') return fleet def get_fleet(resultLimit, attName="", attVal=""): """ Query IOT with the given attribute name and value; return an array of the result. Used if we want to get the set of things to move's current state from IoT Returns: fleet Array of JSON. Each entry is a thing, with info from its device shadow """ nextToke = "" """ Response from IOT for iot.list_things will be: { 'things': [ { 'thingName': 'string', 'attributes': { 'string': 'string' } }, ], 'nextToken': 'string' } """ # TO FIX: Put in loop, till there's no nextToken or we've reached the desired limit # Or, more simply: how many things will it respond with? Don't accept more ;-) thingList = iot.list_things( nextToken=nextToke, maxResults=resultLimit, attributeName=attName, attributeValue=attVal ) if "nextToken" in thingList: nextToke = thingList["nextToken"] logging.warning("There are more things matching this attribute than we are returning.") else: #nextToke = "" #print("No nextToken") pass fleet = thingList['things'] #print("Fleet: ", json.dumps(thingList['things'])) if len(fleet) == 0: return fleet # If there's entries there: get their shadows. for f in fleet: thing = iotdata.get_thing_shadow( thingName=f["thingName"] ) thingState = thing['payload'].read() j = json.loads(thingState) reported = j["state"] # Add/merge this thing's shadow to its fleet entry f.update(reported) return fleet def move_fleet(fleet, dolo): """ Move every thing in "fleet". If this thing has a desired location, head it in that direction. Generate a new ignition state (on = 1, off = 0). If it's on, move it a distance based on its speed and heading. Generate a new speed and heading. Update it's shadow to match. # TO DO: move a random subset each time, instead of all of them? Parameters: fleet array of JSON dolo boolean: get the address for that location, or not. Returns: fleet (updated with new info) """ logging.info("Starting move_fleet, for " + str(len(fleet)) +" vehicles.") for i in range(0,len(fleet)): # Get where we are #print("Was: " ,str(fleet[i])) vehicle = fleet[i] thingnm = vehicle["thingName"] #print(str(vehicle)) thing = iotdata.get_thing_shadow( thingName=thingnm ) thingState = thing['payload'].read() j = json.loads(thingState) #print("Current state: "+thingnm, json.dumps(j, indent=2)) # FOR SOME REASON, I can't read the thing State here. BUT, I can above. WTF? currState = vehicle try: currState = j["state"] except: # TO FIX: I don't know why some of these aren't working in this section of code. But they aren't. logging.warning("JSON LOAD ERROR: " + str(thingState)) # Generate the next state: turn vehicle on or off # Assume: ignition = 0 means vehicle is off. Set speed to 0. newIgnition = 1 # Generate a new random direction newHead = dirs[random.randint(0,len(dirs)-1)] if currState["reported"]["ignition"] == 0: newIgnition = random.randint(0,1) if "desired" in currState and "location" in currState["desired"]: # Head in that direction, unless already "close enough" (newDist, newHead) = calcDir(currState["reported"]["location"], currState["desired"]["location"]) if newHead is None: # set back newHead = dirs[random.randint(0,len(dirs)-1)] newIgnition = 1 if newDist is not None and newDist < 3: # we'll arbitrarily move them there, turn off newLoc = currState["desired"]["location"] newIgnition = 0 # making them go fast for demo purposes dist2move = args.sleeptime * float(vehicle["reported"]["speed"]) /60 #print(vehicle["address"] + str(float(vehicle["speed"])) +" mph ",str(dist2move)) (newLoc,newAddr) = moveTo(vehicle["reported"]["location"], dist2move,newHead,dolo) if newAddr is None: newAddr = "" # Decide the new speed newSpeed = 0 if newIgnition == 1: newSpeed = random.randint(0,maxSpeed) # Put together the set of new location / speed etc info newPos = { "location": newLoc, "address": newAddr, "ignition": str(newIgnition), "speed": str(newSpeed), "heading": newHead} # Update the IOT shadow with a new reported state stateString = '{ "state": { "reported": ' + json.dumps(newPos) + ' } }' #print(vehicle["thingName"], stateString) updt = iotdata.update_thing_shadow( thingName=vehicle["thingName"], payload=stateString ) #print("Update shadow to:" + json.dumps(updt['payload'].read(), indent=1)) fleet[i].update(newPos) #print("Moved: "+vehicle["thingName"], json.dumps(updt['payload'].read())) # I could update the fleet[i] directly from the reported shadow, instead - # that would ensure accuracy between my internal code and the shadow. # But in this case, I'm acting as though I'm an independent vehicle, and I # don't care what my shadow thinks ;-) logging.info(vehicle["thingName"] + ' now at ' + newAddr.encode('utf-8') + ' going ' + str(newSpeed) + ' mph, headed ' + newHead) return fleet def delete_fleet(fleet): """ Self-explanatory, I hope! """ for i in range(0,len(fleet)): thingNm = fleet[i]["thingName"] response = iot.delete_thing(thingName=thingNm) logging.warning("Deleted " + str(thingNm)) if __name__ == "__main__": args = get_args() logging.basicConfig(filename = args.logfile, format = '%(asctime)s %(levelname)s %(message)s', level = LOG_LEVEL) logging.info('***** START EXECUTION *****') if int(args.vehicles) > vehicleLimit: logging.critical("Programmed limit of vehicles="+str(vehicleLimit) \ +" exceeded: modify program to change. Program exiting.") sys.exit() companyId = str(args.companyid) # TO DO: generalize: attName, att Value thingPrefix = args.prefix + companyId + '_' # e.g. 'iotfleet_' + companyId + '_' #howMany = args.vehicles logging.info("Run with Parameters: " + str(args)) if not args.test: # If just testing: don't geolocate (converts lat/long to location) # it's time-consuming and unfair to the geolocation service provider geolocator = Nominatim() fleet = [] # If new option selected: create the fleet of " if args.new: fleet = create_fleet(args.vehicles, thingPrefix, args.attname, args.attvalue) else: # Otherwise: get the fleet that match the given attribute name and value fleet = get_fleet(args.vehicles, args.attname, args.attvalue) if len(fleet) == 0: logging.critical("No existing entries in the fleet ...") # TO DO: What else do we want to do here? sys.exit() else: logging.info("Now have a fleet with " + str(len(fleet)) + " vehicles.") # Move the fleet the requisite number of times for t in range(0, args.move): logging.info("Time passes... Now at time increment " + str(t) + " of " + str(args.move)) print("Time passes... Now at time increment " + str(t) + " of " + str(args.move)) time.sleep(args.sleeptime) fleet = move_fleet(fleet, args.test) # If we've been asked to: delete the fleet if args.deletefleet: delete_fleet(fleet) # Give up, go home, put your feet up, have a drink. logging.info("***** Program completed. Goodbye! *****")