diff --git a/.gitignore b/.gitignore
index e53ff510047602c1f1616ceec5d2fc1397c8c77d..577d1422637de8a6406aedda5f7f1af3eec2f9a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,6 @@
-*.pyc
 # Created by https://www.toptal.com/developers/gitignore/api/python
 # Edit at https://www.toptal.com/developers/gitignore?templates=python
 
-weights/
-
 ### Python ###
 # Byte-compiled / optimized / DLL files
 __pycache__/
@@ -143,6 +140,7 @@ dmypy.json
 
 # Cython debug symbols
 cython_debug/
-weights/yolov3-veges_best.weights
-weights/yolov3-vattenhallen_best.weights
-src/creds.py
+
+# custom
+weights/
+creds.py
diff --git a/Makefile b/Makefile
index 22b2866bb53321933dc8b11f2a3d48c3517ce46f..8c613cd2285167d70ff8c28fedfa4d22848d3129 100644
--- a/Makefile
+++ b/Makefile
@@ -1,14 +1,12 @@
 .DEFAULT_GOAL := default
 
 LIBSO=1
-export LIBSO
 
 darknet: 
 	git submodule init
 	git submodule update
 
 default: darknet
-	$(MAKE) -C darknet
-
-.PHONY: default
+	$(MAKE) LIBSO=$(LIBSO) -C darknet
 
+.PHONY: default darknet
diff --git a/img/WIN_20211103_21_16_18_Pro.jpg b/img/WIN_20211103_21_16_18_Pro.jpg
deleted file mode 100644
index 27b585013a55f2853a29424ccfeb124cc719edb1..0000000000000000000000000000000000000000
Binary files a/img/WIN_20211103_21_16_18_Pro.jpg and /dev/null differ
diff --git a/img/annotations/WIN_20211103_21_16_18_Pro.txt b/img/annotations/WIN_20211103_21_16_18_Pro.txt
deleted file mode 100644
index 7fd6b5a6fdeeb230cbf138bde46f802ea1928095..0000000000000000000000000000000000000000
--- a/img/annotations/WIN_20211103_21_16_18_Pro.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-3 1055.2865 148.5889 443.2753 73.4710 99.5100
-1 188.5453 22.3466 85.2053 49.0357 99.9200
-3 1048.2261 134.2752 271.3041 76.3464 99.9600
diff --git a/img/locations/WIN_20211101_17_19_37_Pro.txt b/img/locations/WIN_20211101_17_19_37_Pro.txt
deleted file mode 100644
index fdd0b107edc822f4b6d0a84b83d0b5d910757b21..0000000000000000000000000000000000000000
--- a/img/locations/WIN_20211101_17_19_37_Pro.txt
+++ /dev/null
@@ -1 +0,0 @@
-350 700 -100
\ No newline at end of file
diff --git a/img/readme.md b/img/readme.md
deleted file mode 100644
index d6af6012b91c95a8c1a29b38ffad1f453ca7a0e9..0000000000000000000000000000000000000000
--- a/img/readme.md
+++ /dev/null
@@ -1,2 +0,0 @@
-This folder is to store all the photos of the planting bed, 
-and is supposed to be cleaned each time after detection.
\ No newline at end of file
diff --git a/src/client.py b/src/client.py
deleted file mode 100755
index fbb44f6b886a2bb818f4b5f611af6797ee9789cc..0000000000000000000000000000000000000000
--- a/src/client.py
+++ /dev/null
@@ -1,141 +0,0 @@
-'''
-Communicate with the server at the manufacturer
-Not useful for the local drive script
-'''
-import paho.mqtt.client as mqtt
-import json
-import time
-from uuid import uuid4 # 通用唯一标识符 ( Universally Unique Identifier )
-import logging #日志模块
-
-# values over max (and under min) will be clipped
-MAX_X = 2400
-MAX_Y = 1200
-MAX_Z = 469  # TODO test this one!
-
-def coord(x, y, z):
-  return {"kind": "coordinate", "args": {"x": x, "y": y, "z": z}} # 返回json 嵌套对象
-
-def move_request(x, y, z):
-  return {"kind": "rpc_request",  # 返回 json对象,对象内含数组
-          "args": {"label": ""},
-          "body": [{"kind": "move_absolute",
-                    "args": {"location": coord(x, y, z),
-                             "offset": coord(0, 0, 0),
-                             "speed": 100}}]}
-
-def take_photo_request():
-  return {"kind": "rpc_request",
-          "args": {"label": ""}, #label空着是为了在blocking_request中填上uuid,唯一识别码
-          "body": [{"kind": "take_photo", "args": {}}]}
-
-def clip(v, min_v, max_v):
-  if v < min_v: return min_v
-  if v > max_v: return max_v
-  return v
-
-class FarmbotClient(object):
-
-  def __init__(self, device_id, token):
-
-    self.device_id = device_id
-    self.client = mqtt.Client() # 类元素继承了另一个对象
-    self.client.username_pw_set(self.device_id, token) #传入 用户名和密码
-    self.client.on_connect = self._on_connect  #???
-    self.client.on_message = self._on_message
-
-    logging.basicConfig(level=logging.DEBUG,
-                        format="%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s",
-                        filename='farmbot_client.log',
-                        filemode='a')
-    console = logging.StreamHandler()
-    console.setLevel(logging.INFO)
-    console.setFormatter(logging.Formatter("%(asctime)s\t%(message)s"))
-    logging.getLogger('').addHandler(console)
-
-    self.connected = False
-    self.client.connect("clever-octopus.rmq.cloudamqp.com", 1883, 60)  #前面的url要运行按README.md中request_token.py 后面俩是TCP Port, Websocket Port
-    self.client.loop_start()
-    # 初始化函数里就会连接到服务器上,所以每次实例化一个新的client时,就已经连上了
-
-
-  def shutdown(self):
-    self.client.disconnect()
-    self.client.loop_stop()
-
-  def move(self, x, y, z):
-    x = clip(x, 0, MAX_X)
-    y = clip(y, 0, MAX_Y)
-    z = clip(z, 0, MAX_Z)
-    status_ok = self._blocking_request(move_request(x, y, z)) # 发请求
-    logging.info("MOVE (%s,%s,%s) [%s]", x, y, z, status_ok) #存日志,包括执行了什么“move x y z +返回值 ”
-
-  def take_photo(self):
-    # TODO: is this enough? it's issue a request for the photo, but is the actual capture async?
-    status_ok = self._blocking_request(take_photo_request())
-    logging.info("TAKE_PHOTO [%s]", status_ok)
-
-  def _blocking_request(self, request, retries_remaining=3):
-    if retries_remaining==0:
-      logging.error("< blocking request [%s] OUT OF RETRIES", request) #尝试3次,然后在日志中记录错误
-      return False
-
-    self._wait_for_connection() #在哪定义的?
-
-    # assign a new uuid for this attempt
-    self.pending_uuid = str(uuid4())
-    request['args']['label'] = self.pending_uuid #接收move_request函数的json对象
-    logging.debug("> blocking request [%s] retries=%d", request, retries_remaining)
-
-    # send request off 发送请求
-    self.rpc_status = None
-    self.client.publish("bot/" + self.device_id + "/from_clients", json.dumps(request))
-
-    # wait for response
-    timeout_counter = 600  # ~1min 等待1s
-    while self.rpc_status is None:          #这个self.rpc_status 是应答的flag
-      time.sleep(0.1)
-      timeout_counter -= 1
-      if timeout_counter == 0:
-        logging.warn("< blocking request TIMEOUT [%s]", request) #时间到了,无应答
-        return self._blocking_request(request, retries_remaining-1)
-    self.pending_uuid = None
-
-    # if it's ok, we're done!
-    if self.rpc_status == 'rpc_ok':
-      logging.debug("< blocking request OK [%s]", request)
-      return True
-
-    # if it's not ok, wait a bit and retry
-    if self.rpc_status == 'rpc_error':
-      logging.warn("< blocking request ERROR [%s]", request)
-      time.sleep(1)
-      return self._blocking_request(request, retries_remaining-1)
-
-    # unexpected state (???)
-    msg = "unexpected rpc_status [%s]" % self.rpc_status
-    logging.error(msg)
-    raise Exception(msg)
-
-
-  def _wait_for_connection(self):
-    # TODO: better way to do all this async event driven rather than with polling :/
-    timeout_counter = 600  # ~1min
-    while not self.connected: #用一个self.connected判断连上了没有,若没连上,等待
-      time.sleep(0.1)
-      timeout_counter -= 1
-      if timeout_counter == 0:
-        raise Exception("unable to connect")
-
-  def _on_connect(self, client, userdata, flags, rc):
-    logging.debug("> _on_connect")
-    self.client.subscribe("bot/" + self.device_id + "/from_device")
-    self.connected = True
-    logging.debug("< _on_connect")
-
-  def _on_message(self, client, userdata, msg):
-    resp = json.loads(msg.payload.decode())
-    if resp['args']['label'] != 'ping':
-      logging.debug("> _on_message [%s] [%s]", msg.topic, resp)
-    if msg.topic.endswith("/from_device") and resp['args']['label'] == self.pending_uuid:
-      self.rpc_status = resp['kind']
diff --git a/src/gripper.py b/src/gripper.py
index d53b714fc353c24ca9f0435d3f0dc8262a09aa92..27b72215533b7f2414874aca6fb8068055dd86e7 100755
--- a/src/gripper.py
+++ b/src/gripper.py
@@ -1,11 +1,24 @@
-#!/usr/bin/env python3
-import serial
+"""Functions to manipulate gripper.
+
+TODO: Integrate with FarmbotClient or FarmbotYoloClient
+"""
+from utils.client import FarmbotClient
+from utils.creds import device_id
+from utils.creds import token
+
+GRIPPER_PIN = 12
+GRIPPER_OPEN_STATE = 0
+GRIPPER_CLOSED_STATE = 1
+
 def gripper_open():
-    ser = serial.Serial('/dev/ttyUSB0')
-    ser.write(str.encode("o"))
-    ser.close
+    """Manipulate gripper by setting pin to 1."""
+    client = FarmbotClient(device_id, token)
+    client.write_pin(GRIPPER_PIN, GRIPPER_OPEN_STATE, pin_mode="digital")
+    client.shutdown()
+
 
 def gripper_close():
-    ser = serial.Serial('/dev/ttyUSB0')
-    ser.write(str.encode("c"))
-    ser.close
\ No newline at end of file
+    """Manipulate gripper by setting pin to 0."""
+    client = FarmbotClient(device_id, token)
+    client.write_pin(GRIPPER_PIN, GRIPPER_CLOSED_STATE, pin_mode="digital")
+    client.shutdown()
diff --git a/src/main.py b/src/main.py
index c68b26834577376f28ee6adbdeb8bf66f9ee69db..d719b2a8023d4f8d9747b4c0ec0a6f81bd68707e 100644
--- a/src/main.py
+++ b/src/main.py
@@ -48,7 +48,6 @@ def remove_overlap(table_coordinate:DataFrame, tolerance=50.00)->DataFrame:
     return table_coordinate              
 
 
-
 def remove_temp(path: Path)-> None:
     '''
     Clean temporary files, i.e., photos, location.txt, annotations
diff --git a/src/move.py b/src/move.py
index 3768b918c06a894d58bd0311faac64012f0eed35..afa038ffd45ee74d472ff4473201a08e631858fc 100644
--- a/src/move.py
+++ b/src/move.py
@@ -8,6 +8,7 @@ Note: it is for remote server, can ben replaced by a local script
 from argparse import ArgumentParser
 from logging import getLogger
 from os import path, makedirs, system
+import sys
 from time  import sleep, strftime, time
 #from serial import Serial, PARITY_NONE, STOPBITS_ONE, EIGHTBITS 
 from requests.api import delete
@@ -20,8 +21,8 @@ from datetime import timezone, datetime
 from dateutil.parser import parse
 from requests import get, delete
 
-import creds
-from client import FarmbotClient
+import utils.creds as creds
+from utils.client import FarmbotClient
 
 
 _SWEEEP_HEIGHT = 0
@@ -40,7 +41,7 @@ class Opts:
     
 
 def scan(img_path: Path, location_path: Path, # smaller delta
-         min_x=0, max_x=1300, min_y=0, max_y=1000, delta=1000, offset=0, flag=True) -> List: #里面的数字需要重新测量
+         min_x=0, max_x=1175, min_y=0, max_y=974, delta=300, offset=0, flag=True) -> List: #里面的数字需要重新测量
     '''
     scan the bed at a certain height, first move along x axis, then y, like a zig zag;
     Taking pictures and record the location of the camera that corresponds to the picture
@@ -76,7 +77,8 @@ def scan(img_path: Path, location_path: Path, # smaller delta
     client.move(0, 0, _SWEEEP_HEIGHT) # ensure moving from original 
     for x, y in pts:
         client.move(x, y, _SWEEEP_HEIGHT) # move camera
-        take_photo(img_path)
+        #take_photo(img_path)
+        client.take_photo()
     client.shutdown()
     # write to img/location
     with open(path.join(location_path, "location.txt"), 'w') as f:
@@ -85,14 +87,21 @@ def scan(img_path: Path, location_path: Path, # smaller delta
     return None 
 
 
-def take_photo(img_path: Path):
-    HERE = path.dirname(__file__)
-    IMG_DIR = path.join(HERE, img_path)
+def take_photo():
+    client = FarmbotClient(creds.device_id, creds.token)
+    client.take_photo()
+    # download image
+    system('python ./utils/download.py')
+
+
+# def take_photo(img_path: Path):
+#     HERE = path.dirname(__file__)
+#     IMG_DIR = path.join(HERE, img_path)
 
-    with request.urlopen('http://localhost:8080/?action=snapshot') as photo:
-        filename = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + ".jpg"
-        with open(path.join(IMG_DIR, filename), mode="wb") as save_file:
-            save_file.write(photo.read())
+#     with request.urlopen('http://localhost:8080/?action=snapshot') as photo:
+#         filename = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + ".jpg"
+#         with open(path.join(IMG_DIR, filename), mode="wb") as save_file:
+#             save_file.write(photo.read())
 
 
 def simple_move(x: int, y: int, z: int) -> None: 
@@ -145,12 +154,14 @@ if __name__ == '__main__':
         destination_x = int(input('X:'))
         destination_y = int(input('Y:'))
         destination_z = int(input('Z:'))
-        photo = True if input('Take a photo or not?[Y/N]:') == 'Y' else False 
         simple_move_start = time()
-        simple_move(destination_x, destination_y, destination_z, photo)
+        simple_move(destination_x, destination_y, destination_z)
         Logger.info(f'time cost {time()-simple_move_start}')
     elif arguments.mode == 2:
         scan(arguments.photo, arguments.locations, flag=False)
+        #take_photo(arguments.photo)
+    elif arguments.mode == 3:
+        take_photo()
     else:
         Logger.error('Wrong mode number {arguments.mode}')
 
diff --git a/src/utils/client.py b/src/utils/client.py
new file mode 100755
index 0000000000000000000000000000000000000000..296fcc467546b1e1da63d8072ff47207e96f3698
--- /dev/null
+++ b/src/utils/client.py
@@ -0,0 +1,245 @@
+"""Communicate with the server at the manufacturer.
+
+Not useful for the local drive script
+"""
+import paho.mqtt.client as mqtt
+import json
+import time
+from uuid import uuid4  # 通用唯一标识符 ( Universally Unique Identifier )
+import logging  # 日志模块
+
+# values over max (and under min) will be clipped
+MAX_X = 2400
+MAX_Y = 1200
+MAX_Z = 469  # TODO test this one!
+
+
+def coord(x, y, z):
+    return {"kind": "coordinate", "args": {"x": x, "y": y, "z": z}}  # 返回json 嵌套对象
+
+
+def move_request(x, y, z):
+    return {
+        "kind": "rpc_request",  # 返回 json对象,对象内含数组
+        "args": {"label": ""},
+        "body": [
+            {
+                "kind": "move_absolute",
+                "args": {
+                    "location": coord(x, y, z),
+                    "offset": coord(0, 0, 0),
+                    "speed": 100,
+                },
+            }
+        ],
+    }
+
+
+def read_pin_request(pin_number, pin_mode="digital"):
+    modes = {"digital": 0, "analog": 1}
+
+    if pin_mode != "digital":
+        raise NotImplementedError()
+
+    return {
+        "kind": "rpc_request",
+        "args": {"label": ""},
+        "body": [
+            {
+                "kind": "read_pin",
+                "args": {
+                    "label": "pin" + str(pin_number),
+                    "pin_mode": modes[pin_mode] or (modes["digital"]),
+                    "pin_number": pin_number,
+                },
+            }
+        ],
+    }
+
+
+def write_pin_request(pin_number, pin_value, pin_mode="digital"):
+    modes = {"digital": 0, "analog": 1}
+
+    if pin_mode != "digital":
+        raise NotImplementedError()
+
+    return {
+        "kind": "rpc_request",
+        "args": {"label": ""},
+        "body": [
+            {
+                "kind": "write_pin",
+                "args": {
+                    "pin_mode": modes[pin_mode] or (modes["digital"]),
+                    "pin_number": pin_number,
+                    "pin_value": pin_value,
+                },
+            }
+        ],
+    }
+
+
+def toggle_pin_request(pin_number):
+    return {
+        "kind": "rpc_request",
+        "args": {"label": ""},
+        "body": [
+            {
+                "kind": "toggle_pin",
+                "args": {
+                    "pin_number": pin_number,
+                },
+            }
+        ],
+    }
+
+
+def take_photo_request():
+    return {
+        "kind": "rpc_request",
+        "args": {"label": ""},  # label空着是为了在blocking_request中填上uuid,唯一识别码
+        "body": [{"kind": "take_photo", "args": {}}],
+    }
+
+
+def clip(v, min_v, max_v):
+    if v < min_v:
+        return min_v
+    if v > max_v:
+        return max_v
+    return v
+
+
+class FarmbotClient(object):
+    def __init__(self, device_id, token):
+
+        self.device_id = device_id
+        self.client = mqtt.Client()  # 类元素继承了另一个对象
+        self.client.username_pw_set(self.device_id, token)  # 传入 用户名和密码
+        self.client.on_connect = self._on_connect  # ???
+        self.client.on_message = self._on_message
+
+        logging.basicConfig(
+            level=logging.DEBUG,
+            format="%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s",
+            filename="farmbot_client.log",
+            filemode="a",
+        )
+        console = logging.StreamHandler()
+        console.setLevel(logging.INFO)
+        console.setFormatter(logging.Formatter("%(asctime)s\t%(message)s"))
+        logging.getLogger("").addHandler(console)
+
+        self.connected = False
+        self.client.connect(
+            "clever-octopus.rmq.cloudamqp.com", 1883, 60
+        )  # 前面的url要运行按README.md中request_token.py 后面俩是TCP Port, Websocket Port
+        self.client.loop_start()
+        # 初始化函数里就会连接到服务器上,所以每次实例化一个新的client时,就已经连上了
+
+    def shutdown(self):
+        self.client.disconnect()
+        self.client.loop_stop()
+
+    def move(self, x, y, z):
+        x = clip(x, 0, MAX_X)
+        y = clip(y, 0, MAX_Y)
+        z = clip(z, 0, MAX_Z)
+        status_ok = self._blocking_request(move_request(x, y, z))  # 发请求
+        logging.info(
+            "MOVE (%s,%s,%s) [%s]", x, y, z, status_ok
+        )  # 存日志,包括执行了什么“move x y z +返回值 ”
+
+    def take_photo(self):
+        # TODO: is this enough? it's issue a request for the photo, but is the actual capture async?
+        status_ok = self._blocking_request(take_photo_request())
+        logging.info("TAKE_PHOTO [%s]", status_ok)
+
+    def read_pin(self, pin_number, pin_mode="digital"):
+        status_ok = self._blocking_request(
+            read_pin_request(pin_number, pin_mode=pin_mode)
+        )
+        logging.info(f"READ PIN (pin_number: {pin_number}) [{status_ok}]")
+
+    def write_pin(self, pin_number, pin_value, pin_mode="digital"):
+        status_ok = self._blocking_request(
+            write_pin_request(pin_number, pin_value, pin_mode=pin_mode)
+        )
+        logging.info(
+            f"WRITE PIN (pin_number: {pin_number}, pin_value: {pin_value}) [{status_ok}]"
+        )
+
+    def toggle_pin(self, pin_number):
+        status_ok = self._blocking_request(toggle_pin_request(pin_number))
+        logging.info(f"TOGGLE PIN (pin_number: {pin_number}) [{status_ok}]")
+
+    def _blocking_request(self, request, retries_remaining=3):
+        if retries_remaining == 0:
+            logging.error(
+                "< blocking request [%s] OUT OF RETRIES", request
+            )  # 尝试3次,然后在日志中记录错误
+            return False
+
+        self._wait_for_connection()  # 在哪定义的?
+
+        # assign a new uuid for this attempt
+        self.pending_uuid = str(uuid4())
+        request["args"]["label"] = self.pending_uuid  # 接收move_request函数的json对象
+        logging.debug("> blocking request [%s] retries=%d", request, retries_remaining)
+
+        # send request off 发送请求
+        self.rpc_status = None
+        self.client.publish(
+            "bot/" + self.device_id + "/from_clients", json.dumps(request)
+        )
+
+        # wait for response
+        timeout_counter = 600  # ~1min 等待1s
+        while self.rpc_status is None:  # 这个self.rpc_status 是应答的flag
+            time.sleep(0.1)
+            timeout_counter -= 1
+            if timeout_counter == 0:
+                logging.warn("< blocking request TIMEOUT [%s]", request)  # 时间到了,无应答
+                return self._blocking_request(request, retries_remaining - 1)
+        self.pending_uuid = None
+
+        # if it's ok, we're done!
+        if self.rpc_status == "rpc_ok":
+            logging.debug("< blocking request OK [%s]", request)
+            return True
+
+        # if it's not ok, wait a bit and retry
+        if self.rpc_status == "rpc_error":
+            logging.warn("< blocking request ERROR [%s]", request)
+            time.sleep(1)
+            return self._blocking_request(request, retries_remaining - 1)
+
+        # unexpected state (???)
+        msg = "unexpected rpc_status [%s]" % self.rpc_status
+        logging.error(msg)
+        raise Exception(msg)
+
+    def _wait_for_connection(self):
+        # TODO: better way to do all this async event driven rather than with polling :/
+        timeout_counter = 600  # ~1min
+        while not self.connected:  # 用一个self.connected判断连上了没有,若没连上,等待
+            time.sleep(0.1)
+            timeout_counter -= 1
+            if timeout_counter == 0:
+                raise Exception("unable to connect")
+
+    def _on_connect(self, client, userdata, flags, rc):
+        logging.debug("> _on_connect")
+        self.client.subscribe("bot/" + self.device_id + "/from_device")
+        self.connected = True
+        logging.debug("< _on_connect")
+
+    def _on_message(self, client, userdata, msg):
+        resp = json.loads(msg.payload.decode())
+        if resp["args"]["label"] != "ping":
+            logging.debug("> _on_message [%s] [%s]", msg.topic, resp)
+        if (
+            msg.topic.endswith("/from_device")
+            and resp["args"]["label"] == self.pending_uuid
+        ):
+            self.rpc_status = resp["kind"]
diff --git a/src/utils/download.py b/src/utils/download.py
new file mode 100644
index 0000000000000000000000000000000000000000..67caace59ae348c9a5181e23e9d0faebee39516b
--- /dev/null
+++ b/src/utils/download.py
@@ -0,0 +1,102 @@
+"""Download images from Farmbot instance."""
+import argparse
+import os
+from pathlib import Path
+from typing import List
+
+import requests
+
+import utils.creds as creds
+
+
+# note: download only returns 100 at a time!
+# note: we are currently ignoreing placeholders
+
+DEBUG_SKIP_DELETE_FILES = True
+
+IMG_FILE_SUFFIX = ".jpg"
+
+REQUEST_HEADERS = {
+    "Authorization": "Bearer " + creds.token,
+    "content-type": "application/json",
+}
+
+
+def download_images(directory: os.PathLike, delete_after=False) -> List[str]:
+    """Download all images on server, optionally deleting after download.
+
+    Parameters
+    ----------
+    directory
+        directory to store images in
+
+    Raises
+    ------
+    RuntimeError
+        If the server gives a bad response (not 200).
+
+    Returns
+    -------
+    list of str
+        List of filepaths with downloaded images
+    """
+    response = requests.get("https://my.farmbot.io/api/images", headers=REQUEST_HEADERS)
+    json_response = response.json()
+
+    if response.status_code != 200:
+        raise RuntimeError(f"Got status code {response.status_code}.")
+
+    print(json_response)
+    print(f"Got a response containing {len(json_response)}")
+
+    if len(json_response) < 1:
+        return []
+
+    img_paths = []
+
+    for img_dict in json_response:
+        if "placehold.it" in img_dict["attachment_url"]:
+            print("IGNORE! placeholder", img_dict["id"])
+            continue
+
+        server_path: str = img_dict["meta"]["name"]
+
+        if not server_path.startswith("/tmp/images"):
+            print("meta name does not start with /tmp/images")
+            continue
+
+        filename = Path(server_path).stem + IMG_FILE_SUFFIX
+        filepath = Path(directory) / filename
+
+        print(">", filepath)
+
+        # download image from google storage and save locally
+        if filepath.exists():
+            print("File exists, skipping")
+            continue
+
+        img_req = requests.get(img_dict["attachment_url"], allow_redirects=True)
+
+        with filepath.open(mode="wb") as fp:
+            fp.write(img_req.content)
+
+        img_paths.append(filepath)
+
+        # post delete from cloud storage
+        if delete_after:
+            requests.delete(
+                f"https://my.farmbot.io/api/images/{img_dict['id']}",
+                headers=REQUEST_HEADERS,
+            )
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Farmbot YOLO image downloader")
+    parser.add_argument(
+        "download_dir", type=Path, help="Directory to download images too."
+    )
+    parser.add_argument("-d", "--delete", action="store_true")
+
+    args = parser.parse_args()
+
+    download_images(args.download_dir, delete_after=args.delete)
diff --git a/src/request_token.py b/src/utils/request_token.py
similarity index 100%
rename from src/request_token.py
rename to src/utils/request_token.py