├── VERSION ├── MANIFEST.in ├── setup.py ├── LICENSE ├── .gitignore ├── example_threads.py ├── example.py ├── README.md ├── README-fr.md └── farmbot ├── farmbot.py └── farmbot_test.py /VERSION: -------------------------------------------------------------------------------- 1 | 1.1.0 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include LICENSE 3 | include VERSION 4 | recursive-include farmbot *.py 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md") as fh: 4 | long_description = fh.read() 5 | 6 | with open("VERSION") as vers_fh: 7 | version = vers_fh.read() 8 | 9 | setup( 10 | name="farmbot", 11 | version=version, 12 | description="Official FarmBot RPC wrapper library for Python.", 13 | py_modules=["farmbot"], 14 | package_dir={ 15 | "": "farmbot" 16 | }, 17 | classifiers=[ 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8" 20 | ], 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | install_requires=[ 24 | "paho-mqtt >= 1.5", 25 | ], 26 | extras_require={ 27 | "dev": [ 28 | "pytest>=6.2" 29 | ] 30 | }, 31 | url="https://github.com/farmbot-labs/farmbot-py", 32 | author="FarmBot, Inc.", 33 | author_email="contact@farmbot.io" 34 | ) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Farmbot, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | __pypackages__/ 3 | .cache 4 | .coverage 5 | .coverage.* 6 | .dmypy.json 7 | .eggs/ 8 | .env 9 | .hypothesis/ 10 | .installed.cfg 11 | .ipynb_checkpoints 12 | .mypy_cache/ 13 | .nox/ 14 | .prof 15 | .pyre/ 16 | .pytest_cache/ 17 | .Python 18 | .python-version 19 | .pytype/ 20 | .ropeproject 21 | .scrapy 22 | .spyderproject 23 | .spyproject 24 | .tox/ 25 | .venv 26 | .vscode/ 27 | .webassets-cache 28 | *__pycache__* 29 | *.cover 30 | *.egg 31 | *.egg-info/ 32 | *.log 33 | *.manifest 34 | *.mo 35 | *.pot 36 | *.py,cover 37 | *.py[cod] 38 | *.sage.py 39 | *.so 40 | *.spec 41 | *$py.class 42 | *egg-info* 43 | *scratchpad* 44 | /site 45 | build/ 46 | celerybeat-schedule 47 | celerybeat.pid 48 | coverage.xml 49 | db.sqlite3 50 | db.sqlite3-journal 51 | develop-eggs/ 52 | dist/ 53 | dmypy.json 54 | doc/_build/ 55 | docs/_build/ 56 | downloads/ 57 | eggs/ 58 | env.bak/ 59 | env/ 60 | ENV/ 61 | htmlcov/ 62 | instance/ 63 | ipython_config.py 64 | lib/ 65 | lib64/ 66 | local_settings.py 67 | MANIFEST 68 | nosetests.xml 69 | parts/ 70 | pip-delete-this-directory.txt 71 | pip-log.txt 72 | pip-wheel-metadata/ 73 | profile_default/ 74 | pytestdebug.log 75 | pythonenv* 76 | sdist/ 77 | share/python-wheels/ 78 | target/ 79 | var/ 80 | venv.bak/ 81 | venv/ 82 | wheels/ 83 | -------------------------------------------------------------------------------- /example_threads.py: -------------------------------------------------------------------------------- 1 | from farmbot import Farmbot, FarmbotToken 2 | import threading 3 | 4 | # PYTHON MULTITHREAD EXAMPLE. 5 | # ========================================================== 6 | # The main thread has a blocking loop that waits for user 7 | # input. The W/A/S/D keys are used to move FarmBot. Commands 8 | # are entered in a queue that are processed in a background 9 | # thread so as to not be blocked when waiting for keyboard 10 | # input. 11 | # ========================================================== 12 | 13 | 14 | class MyHandler: 15 | def __init__(self, bot): 16 | # Store "W", "A", "S", "D" in a queue 17 | self.queue = [] 18 | # Maintain a flag that lets us know if the bot is 19 | # ready for more commands. 20 | self.busy = True 21 | self.bot = bot 22 | 23 | def add_job(self, direction): 24 | d = direction.capitalize() 25 | if d in ["W", "A", "S", "D"]: 26 | self.queue.append(d) 27 | self.bot.read_status() 28 | 29 | def try_next_job(self): 30 | if (len(self.queue) > 0) and (not self.busy): 31 | command = self.queue.pop(0) 32 | print("sending " + command) 33 | self.busy = True 34 | if command == "W": 35 | return self.bot.move_relative(10, 0, 0) 36 | if command == "A": 37 | return self.bot.move_relative(0, -10, 0) 38 | if command == "S": 39 | return self.bot.move_relative(-10, 0, 0) 40 | if command == "D": 41 | return self.bot.move_relative(0, 10, 0) 42 | 43 | def on_connect(self, bot, mqtt_client): 44 | self.bot.read_status() 45 | pass 46 | 47 | def on_change(self, bot, state): 48 | is_busy = state['informational_settings']['busy'] 49 | if is_busy != self.busy: 50 | if is_busy: 51 | print("Device is busy") 52 | else: 53 | print("Device is idle") 54 | 55 | self.busy = is_busy 56 | self.try_next_job() 57 | 58 | def on_log(self, _bot, log): 59 | print("LOG: " + log['message']) 60 | 61 | def on_response(self, _bot, _response): 62 | pass 63 | 64 | def on_error(self, _bot, response): 65 | print("ERROR: " + response.id) 66 | print("Reason(s) for failure: " + str(response.errors)) 67 | 68 | 69 | if __name__ == '__main__': 70 | raw_token = FarmbotToken.download_token( 71 | "usr@name.com", "pass", "https://my.farm.bot") 72 | fb = Farmbot(raw_token) 73 | handler = MyHandler(fb) 74 | threading.Thread(target=fb.connect, name="foo", args=[handler]).start() 75 | print("ENTER A DIRECTION VIA WASD:") 76 | print(" ^") 77 | print(" W") 78 | print(" < A S >") 79 | print(" D") 80 | print(" v") 81 | 82 | while(True): 83 | direction = input("> ") 84 | handler.add_job(direction) 85 | handler.try_next_job() 86 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from farmbot import Farmbot, FarmbotToken 2 | 3 | # Before we begin, we must download an access token from the 4 | # API. To avoid copy/pasting passwords, it is best to create 5 | # an access token and then store that token securely: 6 | raw_token = FarmbotToken.download_token("test@example.com", 7 | "password", 8 | "https://my.farm.bot") 9 | 10 | # This token is then passed to the Farmbot constructor: 11 | fb = Farmbot(raw_token) 12 | 13 | # If you are just doing testing, such as local development, 14 | # it is possible to skip token creation and login with email 15 | # and password. This is not recommended for production devices: 16 | # fb = Farmbot.login(email="em@i.l", 17 | # password="pass", 18 | # server="https://my.farm.bot") 19 | 20 | # The next step is to call fb.connect(), but we are not ready 21 | # to do that yet. Before we can call connect(), we must 22 | # create a "handler" object. FarmBot control is event-based 23 | # and the handler object is responsible for integrating all 24 | # of those events into a custom application. 25 | # 26 | # At a minimum, the handler must respond to the following 27 | # methods: 28 | # on_connect(self, bot: Farmbot, client: Mqtt) -> None 29 | # on_change(self, bot: Farmbot, state: Dict[Any, Any]) -> None 30 | # on_log(self, _bot: Farmbot, log: Dict[Any, Any]) -> None 31 | # on_error(self, _bot: Farmbot, _response: ErrorResponse) -> None 32 | # on_response(self, _bot: Farmbot, _response: OkResponse) -> None 33 | # 34 | # FarmBotPy will call the appropriate method whenever an event 35 | # is triggered. For example, the method `on_log` will be 36 | # called with the last log message every time a new log 37 | # message is created. 38 | 39 | 40 | class MyHandler: 41 | # The `on_connect` event is called whenever the device 42 | # connects to the MQTT server. You can place initialization 43 | # logic here. 44 | # 45 | # The callback is passed a FarmBot instance, plus an MQTT 46 | # client object (see Paho MQTT docs to learn more). 47 | def on_connect(self, bot, mqtt_client): 48 | # Once the bot is connected, we can send RPC commands. 49 | # Every RPC command returns a unique, random request 50 | # ID. Later on, we can use this ID to track our commands 51 | # as they succeed/fail (via `on_response` / `on_error` 52 | # callbacks): 53 | 54 | request_id1 = bot.move_absolute(x=10, y=20, z=30) 55 | # => "c580-6c-11-94-130002" 56 | print("MOVE_ABS REQUEST ID: " + request_id1) 57 | 58 | request_id2 = bot.send_message("Hello, world!") 59 | # => "2000-31-49-11-c6085c" 60 | print("SEND_MESSAGE REQUEST ID: " + request_id2) 61 | 62 | def on_change(self, bot, state): 63 | # The `on_change` event is most frequently triggered 64 | # event. It is called any time the device's internal 65 | # state changes. Example: Updating X/Y/Z position as 66 | # the device moves across the garden. 67 | # The bot maintains all this state in a single JSON 68 | # object that is broadcast over MQTT constantly. 69 | # It is a very large object, so we are printing it 70 | # only as an example. 71 | print("NEW BOT STATE TREE AVAILABLE:") 72 | print(state) 73 | # Since the state tree is very large, we offer 74 | # convenience helpers such as `bot.position()`, 75 | # which returns an (x, y, z) tuple of the device's 76 | # last known position: 77 | print("Current position: (%.2f, %.2f, %.2f)" % bot.position()) 78 | # A less convenient method would be to access the state 79 | # tree directly: 80 | pos = state["location_data"]["position"] 81 | xyz = (pos["x"], pos["y"], pos["z"]) 82 | print("Same information as before: " + str(xyz)) 83 | 84 | # The `on_log` event fires every time a new log is created. 85 | # The callback receives a FarmBot instance, plus a JSON 86 | # log object. The most useful piece of information is the 87 | # `message` attribute, though other attributes do exist. 88 | def on_log(self, bot, log): 89 | print("New message from FarmBot: " + log['message']) 90 | 91 | # When a response succeeds, the `on_response` callback 92 | # fires. This callback is passed a FarmBot object, as well 93 | # as a `response` object. The most important part of the 94 | # `response` is `response.id`. This `id` will match the 95 | # original request ID, which is useful for cross-checking 96 | # pending operations. 97 | def on_response(self, bot, response): 98 | print("ID of successful request: " + response.id) 99 | 100 | # If an RPC request fails (example: stalled motors, firmware 101 | # timeout, etc..), the `on_error` callback is called. 102 | # The callback receives a FarmBot object, plus an 103 | # ErrorResponse object. 104 | def on_error(self, bot, response): 105 | # Remember the unique ID that was returned when we 106 | # called `move_absolute()` earlier? We can cross-check 107 | # the ID by calling `response.id`: 108 | print("ID of failed request: " + response.id) 109 | # We can also retrieve a list of error message(s) by 110 | # calling response.errors: 111 | print("Reason(s) for failure: " + str(response.errors)) 112 | 113 | 114 | # Now that we have a handler class to use, let's create an 115 | # instance of that handler and `connect()` it to the FarmBot: 116 | handler = MyHandler() 117 | 118 | # Keep in mind that `connect()` will block the current thread. 119 | # If you require parallel operations, consider using a background 120 | # thread or a worker process. 121 | fb.connect(handler) 122 | print("This line will not execute. `connect()` is a blocking call.") 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [:fr: Traduction française disponible ici](README-fr.md) 2 | 3 | # Where Is the Latest Documentation? 4 | 5 | If you are reading this document anywhere other than [The official Github page](https://github.com/FarmBot/farmbot-py), you may be reading old documentation. Please visit Github for the latest documentation. 6 | 7 | # Requirements 8 | 9 | We tested this package with Python 3.8 and `paho-mqtt` 1.5. 10 | 11 | It may work with earlier versions of Python, but Python 3.8 is the supported version. Please do not report bugs with earlier python versions. 12 | 13 | # Installation 14 | 15 | FarmBot publishes the latest version of this package to [PyPi](https://pypi.org/project/farmbot/). You can install the newest version with the following command: 16 | 17 | ``` 18 | pip install farmbot 19 | ``` 20 | 21 | # Unit Testing 22 | 23 | ``` 24 | pip install -e .[dev] 25 | pytest --cov=farmbot --cov-report html 26 | ``` 27 | 28 | # Usage 29 | 30 | ```python 31 | from farmbot import Farmbot, FarmbotToken 32 | 33 | # Before we begin, we must download an access token from the 34 | # API. To avoid copy/pasting passwords, it is best to create 35 | # an access token and then store that token securely: 36 | raw_token = FarmbotToken.download_token("test@example.com", 37 | "password", 38 | "https://my.farm.bot") 39 | 40 | # This token is then passed to the Farmbot constructor: 41 | fb = Farmbot(raw_token) 42 | 43 | # If you are just doing testing, such as local development, 44 | # it is possible to skip token creation and login with email 45 | # and password. This is not recommended for production devices: 46 | # fb = Farmbot.login(email="em@i.l", 47 | # password="pass", 48 | # server="https://my.farm.bot") 49 | 50 | # The next step is to call fb.connect(), but we are not ready 51 | # to do that yet. Before we can call connect(), we must 52 | # create a "handler" object. FarmBot control is event-based 53 | # and the handler object is responsible for integrating all 54 | # of those events into a custom application. 55 | # 56 | # At a minimum, the handler must respond to the following 57 | # methods: 58 | # on_connect(self, bot: Farmbot, client: Mqtt) -> None 59 | # on_change(self, bot: Farmbot, state: Dict[Any, Any]) -> None 60 | # on_log(self, _bot: Farmbot, log: Dict[Any, Any]) -> None 61 | # on_error(self, _bot: Farmbot, _response: ErrorResponse) -> None 62 | # on_response(self, _bot: Farmbot, _response: OkResponse) -> None 63 | # 64 | # FarmBotPy will call the appropriate method whenever an event 65 | # is triggered. For example, the method `on_log` will be 66 | # called with the last log message every time a new log 67 | # message is created. 68 | 69 | 70 | class MyHandler: 71 | # The `on_connect` event is called whenever the device 72 | # connects to the MQTT server. You can place initialization 73 | # logic here. 74 | # 75 | # The callback is passed a FarmBot instance, plus an MQTT 76 | # client object (see Paho MQTT docs to learn more). 77 | def on_connect(self, bot, mqtt_client): 78 | # Once the bot is connected, we can send RPC commands. 79 | # Every RPC command returns a unique, random request 80 | # ID. Later on, we can use this ID to track our commands 81 | # as they succeed/fail (via `on_response` / `on_error` 82 | # callbacks): 83 | 84 | request_id1 = bot.move_absolute(x=10, y=20, z=30) 85 | # => "c580-6c-11-94-130002" 86 | print("MOVE_ABS REQUEST ID: " + request_id1) 87 | 88 | request_id2 = bot.send_message("Hello, world!") 89 | # => "2000-31-49-11-c6085c" 90 | print("SEND_MESSAGE REQUEST ID: " + request_id2) 91 | 92 | def on_change(self, bot, state): 93 | # The `on_change` event is most frequently triggered 94 | # event. It is called any time the device's internal 95 | # state changes. Example: Updating X/Y/Z position as 96 | # the device moves across the garden. 97 | # The bot maintains all this state in a single JSON 98 | # object that is broadcast over MQTT constantly. 99 | # It is a very large object, so we are printing it 100 | # only as an example. 101 | print("NEW BOT STATE TREE AVAILABLE:") 102 | print(state) 103 | # Since the state tree is very large, we offer 104 | # convenience helpers such as `bot.position()`, 105 | # which returns an (x, y, z) tuple of the device's 106 | # last known position: 107 | print("Current position: (%.2f, %.2f, %.2f)" % bot.position()) 108 | # A less convenient method would be to access the state 109 | # tree directly: 110 | pos = state["location_data"]["position"] 111 | xyz = (pos["x"], pos["y"], pos["z"]) 112 | print("Same information as before: " + str(xyz)) 113 | 114 | # The `on_log` event fires every time a new log is created. 115 | # The callback receives a FarmBot instance, plus a JSON 116 | # log object. The most useful piece of information is the 117 | # `message` attribute, though other attributes do exist. 118 | def on_log(self, bot, log): 119 | print("New message from FarmBot: " + log['message']) 120 | 121 | # When a response succeeds, the `on_response` callback 122 | # fires. This callback is passed a FarmBot object, as well 123 | # as a `response` object. The most important part of the 124 | # `response` is `response.id`. This `id` will match the 125 | # original request ID, which is useful for cross-checking 126 | # pending operations. 127 | def on_response(self, bot, response): 128 | print("ID of successful request: " + response.id) 129 | 130 | # If an RPC request fails (example: stalled motors, firmware 131 | # timeout, etc..), the `on_error` callback is called. 132 | # The callback receives a FarmBot object, plus an 133 | # ErrorResponse object. 134 | def on_error(self, bot, response): 135 | # Remember the unique ID that was returned when we 136 | # called `move_absolute()` earlier? We can cross-check 137 | # the ID by calling `response.id`: 138 | print("ID of failed request: " + response.id) 139 | # We can also retrieve a list of error message(s) by 140 | # calling response.errors: 141 | print("Reason(s) for failure: " + str(response.errors)) 142 | 143 | 144 | # Now that we have a handler class to use, let's create an 145 | # instance of that handler and `connect()` it to the FarmBot: 146 | handler = MyHandler() 147 | 148 | # Once `connect` is called, execution of all other code will 149 | # be pause until an event occurs, such as logs, errors, 150 | # status updates, etc.. 151 | # If you need to run other code while `connect()` is running, 152 | # consider using tools like system threads or processes. 153 | # See: `example_threads.py` for inspiration. 154 | fb.connect(handler) 155 | print("This line will not execute. `connect()` is a blocking call.") 156 | ``` 157 | 158 | # Supported Remote Procedure Calls 159 | 160 | The currently supported list of commands is below. 161 | 162 | Please create an issue if you would to request a new command. 163 | 164 | * bot.position() -> (x, y, z) 165 | * bot.emergency_lock() 166 | * bot.emergency_unlock() 167 | * bot.factory_reset() 168 | * bot.find_home() 169 | * bot.find_length(axis="all") 170 | * bot.flash_farmduino(package="farmduino") (or "arduino", "express_k10", "farmduino_k14") 171 | * bot.go_to_home(axis="all", speed=100) 172 | * bot.move_absolute(x, y, z, speed=100.0) 173 | * bot.move_relative(x, y, z, speed=100) 174 | * bot.power_off() 175 | * bot.read_pin(pin_number, pin_mode="digital") (NOTE: Results appear in state tree) 176 | * bot.read_status() 177 | * bot.reboot() 178 | * bot.reboot_farmduino() 179 | * bot.send_message(msg, type="info") 180 | * bot.set_servo_angle(pin_number, angle) 181 | * bot.sync() 182 | * bot.take_photo() 183 | * bot.toggle_pin(pin_number) 184 | * bot.update_farmbot_os() 185 | * bot.write_pin(pin_number, pin_value, pin_mode="digital" ) 186 | * bot.lua(lua_string) 187 | 188 | # Not Yet Supported 189 | 190 | * Ability to execute an existing sequence. 191 | * REST resource management. 192 | 193 | # Building and Publishing the Package (For FarmBot Employees) 194 | 195 | We follow a standard Pip / PyPI workflow. See [this excelent tutorial](https://www.youtube.com/watch?v=GIF3LaRqgXo&t=1527s) for details. 196 | 197 | ``` 198 | python3 setup.py bdist_wheel sdist 199 | twine upload dist/* 200 | ``` 201 | -------------------------------------------------------------------------------- /README-fr.md: -------------------------------------------------------------------------------- 1 | :warning: | Vous lisez la traduction française de la documentation, elle peut ne pas être totalement à jour. En cas de doute, consultez la [version anglaise](README.md). 2 | :---: | :--- 3 | 4 | Ce _package_ est prêt à être utilisé, mais n'a pas été testé de manière approfondie dans le monde réel. 5 | 6 | Les signalements de bugs sont bienvenus. 7 | 8 | # Dépendances (_Requirements_) 9 | 10 | Ce _package_ a été testé avec Python 3.8 et `paho-mqtt` 1.5. 11 | 12 | Il se peut qu'il fonctionne avec des versions antérieures de Python, mais le support n'est assuré qu'avec Python 3.8. 13 | Merci de ne pas signaler de bugs liés à des versions antérieures. 14 | 15 | # Installation 16 | 17 | Farmbot publie la version la plus récente de ce _package_ sur [PyPi](https://pypi.org/project/farmbot/). Vous pouvez l'installer avec la commande suivante : 18 | 19 | ``` 20 | pip install farmbot 21 | ``` 22 | 23 | # Tests unitaires 24 | 25 | ``` 26 | pip install -e .[dev] 27 | pytest --cov=farmbot --cov-report html 28 | ``` 29 | 30 | # Utilisation 31 | 32 | ```python 33 | from farmbot import Farmbot, FarmbotToken 34 | 35 | 36 | # Avant tout, nous devons obtenir un jeton d'accès (access token) auprès de l'API. 37 | # Pour éviter de copier/coller des mots de passe, il est préférable de créer 38 | # un jeton d'accès et de le stocker de manière sécurisée. 39 | raw_token = FarmbotToken.download_token("test@example.com", 40 | "password", 41 | "https://my.farm.bot") 42 | 43 | # Ce jeton est ensuite passé au constructeur Farmbot : 44 | fb = Farmbot(raw_token) 45 | 46 | # Si vous réalisez des tests, du développement local, 47 | # il est possible de sauter l'étape de création de jeton et de s'authentifier avec un email 48 | # et un mot de passe. Ce n'est cependant pas recommandé pour les robots en production : 49 | # fb = Farmbot.login(email="em@i.l", 50 | # password="pass", 51 | # server="https://my.farm.bot") 52 | 53 | # L'étape suivante est d'appeler fb.connect(), mais nous ne sommes pas encore prêts 54 | # à le faire. Avant de pouvoir appeler fb.connect(), nous devons créer 55 | # un objet "handler" (gestionnaire d'évènements). 56 | # Le contrôle du FarmBot est basé sur des évènements et l'objet handler sert à intégrer 57 | # tous ces évènements dans une application sur mesure. 58 | # 59 | # Au minimum, le handler doit implémenter les méthodes suivantes : 60 | # on_connect(self, bot: Farmbot, client: Mqtt) -> None 61 | # on_change(self, bot: Farmbot, state: Dict[Any, Any]) -> None 62 | # on_log(self, _bot: Farmbot, log: Dict[Any, Any]) -> None 63 | # on_error(self, _bot: Farmbot, _response: ErrorResponse) -> None 64 | # on_response(self, _bot: Farmbot, _response: OkResponse) -> None 65 | # 66 | # FarmbotPy appellera la méthode appropriée à chaque fois qu'un évènement est déclenché. 67 | # Par exemple, la méthode `on_log` sera appelée avec le dernier message 68 | # à chaque fois qu'un nouveau log est créé. 69 | 70 | class MyHandler: 71 | # L'évènement `on_connect` est appelé à chaque fois que le robot 72 | # se connecte au serveur MQTT. C'est à cet endroit que vous pouvez placer 73 | # la logique d'initialisation. 74 | # 75 | # Le callback (fonction de rappel) reçoit deux arguments : l'instance Farmbot, 76 | # et un objet client MQTT (voir la documentation de Paho MQTT pour en savoir plus) 77 | def on_connect(self, bot, mqtt_client): 78 | # Une fois connecté au robot, nous pouvons envoyer des commandes RPC. 79 | # Chaque commande RPC retourne un identifiant de requête unique et aléatoire. 80 | # Nous pouvons ensuite utiliser cet identifiant pour être informé 81 | # du succès ou de l'échec de nos requêtes (via les callbacks `on_response` / `on_error`): 82 | 83 | request_id1 = bot.move_absolute(x=10, y=20, z=30) 84 | # => "c580-6c-11-94-130002" 85 | print("Identifiant de requête MOVE_ABS: " + request_id1) 86 | 87 | request_id2 = bot.send_message("Hello, world!") 88 | # => "2000-31-49-11-c6085c" 89 | print("Idnetifiant de requête SEND_MESSAGE: " + request_id2) 90 | 91 | def on_change(self, bot, state): 92 | # `on_change` est l'évènement le plus fréquemment déclenché. 93 | # Il est appelé à chaque fois que l'état interne du robot change. 94 | # Exemple : lors de la mise à jour de la position X/Y/Z 95 | # lorsque le robot se déplace dans le jardin. 96 | # L'état interne est contenu dans un seul object JSON 97 | # qui est diffusé (broadcast) en permanence via le protocole MQTT. 98 | # C'est un très gros objet, qui n'est affiché ici qu'à titre d'exemple. 99 | print("Un nouvel état du robot est disponible :") 100 | print(state) 101 | # Comme l'arbre d'état est très volumineux, nous proposons des fonctions helpers 102 | # pour faciliter l'accès à certaines données, tel que `bot.position()` qui retourne 103 | # un tuple (x, y, z) de la dernière position connue du robot : 104 | print("Position actuelle : (%.2f, %.2f, %.2f)" % bot.position()) 105 | pos = state["location_data"]["position"] 106 | xyz = (pos["x"], pos["y"], pos["z"]) 107 | print("Même information : " + str(xyz)) 108 | 109 | # L'évènement `on_log` est déclenché à chaque fois qu'un nouveau log est créé. 110 | # Le callback reçoit deux arguments : une instance Farmbot, et un objet log au format JSON. 111 | # L'information la plus utile est l'attribut `message`, bien que d'autres attributs soient également disponibles. 112 | def on_log(self, bot, log): 113 | print("New message from FarmBot: " + log['message']) 114 | 115 | # Quand une requête est couronnée de succès, le callback `on_response` se déclenche. 116 | # Ce callback reçoit deux arguments : un objet Farmbot, et un objet `response`. 117 | # La partie la plus importante de `response` est `response.id`. Cet `id` correspond 118 | # à l'identifiant de requête d'origine, ce qui est utile pour recouper les opérations en attente. 119 | def on_response(self, bot, response): 120 | print("Identifiant de la requête réussie : " + response.id) 121 | 122 | # Si une requête RPC échoue (exemple : moteurs bloqués, timeout du firmware, etc.), 123 | # le callback `on_error` est appelé. 124 | # Le callback reçoit deux arguments : un objet Farmbot, et un objet ErrorResponse. 125 | def on_error(self, bot, response): 126 | # Vous rappelez-vous de l'identifiant unique qui était retourné 127 | # quand nous appelions `move_absolute` un peu plus haut ? 128 | # Nous pouvons le retrouver en affichant `response.id`: 129 | print("Identifiant de la requête en échec :" + response.id) 130 | # Nous pouvons également récupérer une liste de messages d'erreur 131 | # en affichant `response.errors` 132 | print("Cause(s) de l'échec : " + str(response.errors)) 133 | 134 | 135 | # Maintenant que nous avons une classe gestionnaire d'évènements à disposition, 136 | # créons une instance de ce handler et connectons-la au Farmbot : 137 | handler = MyHandler() 138 | 139 | # Une fois que `connect` est appelé, l'exécution de tout autre code est suspendue 140 | # jusqu'à qu'un évènement se produise (logs, erreurs, mise à jour du statut, etc.). 141 | # Si vous avez besoin d'exécuter autre chose pendant que `connect()` est actif, 142 | # envisagez l'utilisation de threads (https://docs.python.org/fr/3/library/threading.html) 143 | # ou de processus (https://docs.python.org/fr/3/library/multiprocessing.html). 144 | fb.connect(handler) 145 | print("Cette ligne ne s'exécutera pas. `connect()` est une commande bloquante.") 146 | ``` 147 | 148 | # Remote Procedure Calls (RPC) supportés 149 | 150 | Vous trouverez ci-dessous la liste des commandes actuellement supportées. 151 | 152 | Merci de créer une _issue_ si vous souhaitez demander une nouvelle commande. 153 | 154 | * bot.position() -> (x, y, z) 155 | * bot.emergency_lock() 156 | * bot.emergency_unlock() 157 | * bot.factory_reset() 158 | * bot.find_home() 159 | * bot.find_length(axis="all") 160 | * bot.flash_farmduino(package="farmduino") (ou "arduino", "express_k10", "farmduino_k14") 161 | * bot.go_to_home(axis="all", speed=100) 162 | * bot.move_absolute(x, y, z, speed=100.0) 163 | * bot.move_relative(x, y, z, speed=100) 164 | * bot.power_off() 165 | * bot.read_pin(pin_number, pin_mode="digital") (N.B. : les résultats apparaissent dans l'arbre d'état) 166 | * bot.read_status() 167 | * bot.reboot() 168 | * bot.reboot_farmduino() 169 | * bot.send_message(msg, type="info") 170 | * bot.set_servo_angle(pin_number, angle) 171 | * bot.sync() 172 | * bot.take_photo() 173 | * bot.toggle_pin(pin_number) 174 | * bot.update_farmbot_os() 175 | * bot.write_pin(pin_number, pin_value, pin_mode="digital" ) 176 | 177 | # Non supporté pour le moment 178 | 179 | * Capacité à exécuter une _séquence_ Farmbot existante 180 | * Gestion des ressources via une API REST 181 | 182 | # _Build_ et publication du _package_ 183 | 184 | Nous suivons un workflow standard Pip / PyPI. Voyez [cet excellent tutoriel](https://www.youtube.com/watch?v=GIF3LaRqgXo&t=1527s) pour plus de détails. 185 | -------------------------------------------------------------------------------- /farmbot/farmbot.py: -------------------------------------------------------------------------------- 1 | import paho.mqtt.client as mqtt 2 | from urllib.request import urlopen, Request 3 | import json 4 | import uuid 5 | 6 | 7 | class OkResponse(): 8 | def __init__(self, id): 9 | self.id = id 10 | 11 | 12 | class ErrorResponse(): 13 | def __init__(self, id, errors): 14 | self.errors = errors 15 | self.id = id 16 | 17 | 18 | class FarmbotConnection(): 19 | def __init__(self, bot, mqtt=mqtt.Client()): 20 | self.bot = bot 21 | self.mqtt = mqtt 22 | u = bot.username 23 | self.mqtt.username_pw_set(u, bot.password) 24 | # bot/device_000/from_clients 25 | # bot/device_000/from_device 26 | # bot/device_000/logs 27 | # bot/device_000/status 28 | # bot/device_000/sync/# 29 | # bot/device_000/ping/# 30 | # bot/device_000/pong/# 31 | self.status_chan = "bot/" + u + "/status" 32 | self.logs_chan = "bot/" + u + "/logs" 33 | self.incoming_chan = "bot/" + u + "/from_device" 34 | self.outgoing_chan = "bot/" + u + "/from_clients" 35 | self.channels = ( 36 | self.status_chan, 37 | self.logs_chan, 38 | self.incoming_chan 39 | ) 40 | 41 | def start_connection(self): 42 | # Attach event handlers: 43 | self.mqtt.on_connect = self.handle_connect 44 | self.mqtt.on_message = self.handle_message 45 | 46 | # Finally, connect to the server: 47 | self.mqtt.connect(self.bot.hostname, 1883, 60) 48 | 49 | self.mqtt.loop_forever() 50 | 51 | def stop_connection(self): 52 | self.mqtt.disconnect() 53 | 54 | def handle_connect(self, mqtt, userdata, flags, rc): 55 | for channel in self.channels: 56 | mqtt.subscribe(channel) 57 | self.bot.read_status() 58 | self.bot._handler.on_connect(self.bot, mqtt) 59 | 60 | def handle_message(self, mqtt, userdata, msg): 61 | if msg.topic == self.status_chan: 62 | self.handle_status(msg) 63 | 64 | if msg.topic == self.logs_chan: 65 | self.handle_log(msg) 66 | 67 | if msg.topic == self.incoming_chan: 68 | self.unpack_response(msg.payload) 69 | 70 | def unpack_response(self, payload): 71 | resp = json.loads(payload) 72 | kind = resp["kind"] 73 | label = resp["args"]["label"] 74 | if kind == "rpc_ok": 75 | self.handle_resp(label) 76 | if kind == "rpc_error": 77 | self.handle_error(label, resp["body"] or []) 78 | 79 | def handle_resp(self, label): 80 | # { 81 | # 'kind': 'rpc_ok', 82 | # 'args': { 'label': 'fd0ee7c9-6ca8-11eb-9d9d-eba70539ce61' }, 83 | # } 84 | self.bot._handler.on_response(self.bot, OkResponse(label)) 85 | return 86 | 87 | def handle_status(self, msg): 88 | self.bot.state = json.loads(msg.payload) 89 | self.bot._handler.on_change(self.bot, self.bot.state) 90 | return 91 | 92 | def handle_log(self, msg): 93 | log = json.loads(msg.payload) 94 | self.bot._handler.on_log(self.bot, log) 95 | return 96 | 97 | def handle_error(self, label, errors): 98 | # { 99 | # 'kind': 'rpc_error', 100 | # 'args': { 'label': 'fd0ee7c8-6ca8-11eb-9d9d-eba70539ce61' }, 101 | # 'body': [ 102 | # { 'kind': 'explanation', 'args': { 'message': '~ERROR~' } }, 103 | # { 'kind': 'explanation', 'args': { 'message': '~ERROR~' } }, 104 | # ], 105 | # } 106 | 107 | tidy_errors = [] 108 | for error in errors: 109 | args = error["args"] or {"message": "No message provided"} 110 | message = args["message"] 111 | tidy_errors.append(message) 112 | self.bot._handler.on_error(self.bot, ErrorResponse(label, tidy_errors)) 113 | return 114 | 115 | def send_rpc(self, rpc): 116 | label = str(uuid.uuid1()) 117 | message = {"kind": "rpc_request", "args": {"label": label}} 118 | if isinstance(rpc, list): 119 | message["body"] = rpc 120 | else: 121 | message["body"] = [rpc] 122 | payload = json.dumps(message) 123 | self.mqtt.publish(self.outgoing_chan, payload) 124 | return label 125 | 126 | 127 | class StubHandler: 128 | def on_connect(self, bot, client): pass 129 | def on_change(self, bot, state): pass 130 | def on_log(self, _bot, log): pass 131 | def on_error(self, _bot, _response): pass 132 | def on_response(self, _bot, _response): pass 133 | 134 | 135 | class FarmbotToken(): 136 | @staticmethod 137 | def download_token(email, 138 | password, 139 | server="https://my.farm.bot"): 140 | """ 141 | Returns a byte stream representation of the 142 | """ 143 | req = Request(server + "/api/tokens") 144 | req.add_header("Content-Type", "application/json") 145 | data = {"user": {"email": email, "password": password}} 146 | string = json.dumps(data) 147 | as_bytes = str.encode(string) 148 | response = urlopen(req, as_bytes) 149 | 150 | return response.read() 151 | 152 | def __init__(self, raw_token): 153 | token_data = json.loads(raw_token) 154 | self.raw_token = raw_token 155 | token = token_data["token"]["unencoded"] 156 | self.jwt = token_data["token"]["encoded"] 157 | 158 | self.bot = token["bot"] 159 | self.exp = token["exp"] 160 | self.iat = token["iat"] 161 | self.iss = token["iss"] 162 | self.jti = token["jti"] 163 | self.mqtt = token["mqtt"] 164 | self.mqtt_ws = token["mqtt_ws"] 165 | self.sub = token["sub"] 166 | self.vhost = token["vhost"] 167 | 168 | 169 | empty_xyz = {"x": None, "y": None, "z": None} 170 | zero_xyz = {"x": 0, "y": 0, "z": 0} 171 | 172 | 173 | def empty_state(): 174 | """ 175 | This is what the bot's state tree looks like. 176 | This helper method creates an empty object that is mostly 177 | unpopulated. 178 | """ 179 | return { 180 | "configuration": {}, 181 | "informational_settings": {}, 182 | "jobs": {}, 183 | "location_data": { 184 | "axis_states": empty_xyz, 185 | "load": empty_xyz, 186 | "position": empty_xyz, 187 | "raw_encoders": empty_xyz, 188 | "scaled_encoders": empty_xyz, 189 | }, 190 | "mcu_params": {}, 191 | "pins": {}, 192 | "user_env": {} 193 | } 194 | 195 | 196 | class Farmbot(): 197 | @classmethod 198 | def login(cls, 199 | email, 200 | password, 201 | server="https://my.farm.bot"): 202 | """ 203 | We reccomend that users store tokens rather than passwords. 204 | """ 205 | token = FarmbotToken.download_token(email=email, 206 | password=password, 207 | server=server) 208 | return Farmbot(token) 209 | 210 | def __init__(self, raw_token): 211 | token = FarmbotToken(raw_token) 212 | self.username = token.bot 213 | self.password = token.jwt 214 | self.hostname = token.mqtt 215 | self.device_id = token.sub 216 | self._handler = StubHandler() 217 | 218 | self._connection = FarmbotConnection(self) 219 | self.state = empty_state() 220 | 221 | def connect(self, handler): 222 | """ 223 | Attempt to connect to the MQTT broker. 224 | """ 225 | self._handler = handler 226 | self._connection.start_connection() 227 | 228 | def disconnect(self): 229 | self._connection.stop_connection() 230 | 231 | def position(self): 232 | """ 233 | Convinence method to return the bot's current location 234 | as an (x, y, z) tuple. 235 | """ 236 | position = self.state["location_data"]["position"] 237 | x = position["x"] or -0.0 238 | y = position["y"] or -0.0 239 | z = position["z"] or -0.0 240 | return (x, y, z) 241 | 242 | def _do_cs(self, kind, args, body=[]): 243 | """ 244 | This is a private helper that wraps CeleryScript in 245 | an `rpc` node and sends it to the device over MQTT. 246 | """ 247 | return self._connection.send_rpc({ 248 | "kind": kind, 249 | "args": args, 250 | "body": body 251 | }) 252 | 253 | def move_absolute(self, x, y, z, speed=100.0): 254 | """ 255 | Move to an absolute XYZ coordinate at a speed percentage (default speed: 100%). 256 | """ 257 | return self._do_cs("move_absolute", { 258 | "location": {"kind": "coordinate", "args": {"x": x, "y": y, "z": z, }}, 259 | "speed": speed, 260 | "offset": {"kind": "coordinate", "args": zero_xyz} 261 | }) 262 | 263 | def send_message(self, msg, type="info"): 264 | """ 265 | Send a log message. 266 | """ 267 | return self._do_cs("send_message", 268 | {"message": msg, "message_type": type, }) 269 | 270 | def emergency_lock(self): 271 | """ 272 | Perform an emergency stop, thereby preventing any 273 | motor movement until `emergency_unlock()` is called. 274 | """ 275 | return self._do_cs("emergency_lock", {}) 276 | 277 | def emergency_unlock(self): 278 | """ 279 | Unlock the Farmduino, allowing movement of previously 280 | locked motors. 281 | """ 282 | return self._do_cs("emergency_unlock", {}) 283 | 284 | def find_home(self): 285 | """ 286 | Find the home (0) position for all axes. 287 | """ 288 | return self._do_cs("find_home", {"speed": 100, "axis": "all"}) 289 | 290 | def find_length(self, axis="all"): 291 | """ 292 | Move to the end of each axis until a stall is detected, 293 | then set that distance as the maximum length. 294 | """ 295 | return self._do_cs("calibrate", {"axis": axis}) 296 | 297 | def flash_farmduino(self, package="farmduino"): 298 | """ 299 | Flash microcontroller firmware. `package` is one of 300 | the following values: "arduino", "express_k10", 301 | "farmduino_k14", "farmduino" 302 | """ 303 | return self._do_cs("flash_firmware", {"package": package}) 304 | 305 | def go_to_home(self, axis="all", speed=100): 306 | """ 307 | Move to the home position for a given axis at a 308 | particular speed. 309 | """ 310 | return self._do_cs("home", {"speed": speed, "axis": axis}) 311 | 312 | def move_relative(self, x, y, z, speed=100): 313 | """ 314 | Move to a relative XYZ offset from the device's current 315 | position at a speed percentage (default speed: 100%). 316 | """ 317 | return self._do_cs("move_relative", 318 | {"x": x, "y": y, "z": z, "speed": speed}) 319 | 320 | def power_off(self): 321 | """ 322 | Deactivate FarmBot OS completely (shutdown). Useful 323 | before unplugging the power. 324 | """ 325 | return self._do_cs("power_off", {}) 326 | 327 | def read_status(self): 328 | """ 329 | Read the status of the bot. Should not be needed unless you are first 330 | logging in to the device, since the device pushes new states out on 331 | every update. 332 | """ 333 | self._do_cs("read_status", {}) 334 | 335 | def reboot(self): 336 | """ 337 | Restart FarmBot OS. 338 | """ 339 | return self._do_cs("reboot", {"package": "farmbot_os"}) 340 | 341 | def reboot_farmduino(self): 342 | """ 343 | Reinitialize the FarmBot microcontroller firmware. 344 | """ 345 | return self._do_cs("reboot", {"package": "arduino_firmware"}) 346 | 347 | def factory_reset(self): 348 | """ 349 | THIS WILL RESET THE SD CARD, deleting all non-factory data! 350 | * Be careful!! * 351 | """ 352 | return self._do_cs("factory_reset", {"package": "farmbot_os"}) 353 | 354 | def sync(self): 355 | """ 356 | Download/apply all of the latest FarmBot API JSON resources (plants, 357 | account info, etc.) to the device. 358 | """ 359 | return self._do_cs("sync", {}) 360 | 361 | def take_photo(self): 362 | """ 363 | Snap a photo and send to the API for post processing. 364 | """ 365 | return self._do_cs("take_photo", {}) 366 | 367 | def toggle_pin(self, pin_number): 368 | """ 369 | Reverse the value of a digital pin. 370 | """ 371 | return self._do_cs("toggle_pin", {"pin_number": pin_number}) 372 | 373 | def update_farmbot_os(self): 374 | return self._do_cs("check_updates", {"package": "farmbot_os"}) 375 | 376 | def read_pin(self, pin_number, pin_mode="digital"): 377 | """ 378 | Read a pin 379 | """ 380 | modes = {"digital": 0, "analog": 1} 381 | args = { 382 | "label": "pin" + str(pin_number), 383 | "pin_mode": modes[pin_mode] or (modes["digital"]), 384 | "pin_number": pin_number 385 | } 386 | return self._do_cs("read_pin", args) 387 | 388 | def write_pin(self, pin_number, pin_value, pin_mode="digital"): 389 | """ 390 | Write to a pin 391 | """ 392 | modes = {"digital": 0, "analog": 1} 393 | args = { 394 | "pin_mode": modes[pin_mode] or (modes["digital"]), 395 | "pin_number": pin_number, 396 | "pin_value": pin_value 397 | } 398 | return self._do_cs("write_pin", args) 399 | 400 | def set_servo_angle(self, pin_number, angle): 401 | """ 402 | Set pins 4 or 5 to a value between 0 and 180. 403 | """ 404 | return self._do_cs("set_servo_angle", 405 | {"pin_number": pin_number, "pin_value": angle}) 406 | 407 | def lua(self, lua_string): 408 | """ 409 | Evaluates a Lua expression on the remote device. 410 | """ 411 | return self._do_cs("lua", {"lua": lua_string}) 412 | 413 | -------------------------------------------------------------------------------- /farmbot/farmbot_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import farmbot as fb 3 | import json 4 | 5 | fake_bytes = b"{\"token\": {\"unencoded\": {\"aud\": \"unknown\", \"sub\": 1, \"iat\": 1609876696, \"jti\": \"46694c2f-2a03-4e3f-84bd-7ec22fb58c1f\", \"iss\": \"//10.11.1.235:3000\", \"exp\": 1613332696, \"mqtt\": \"10.11.1.235\", \"bot\": \"device_2\", \"vhost\": \"/\", \"mqtt_ws\": \"ws://10.11.1.235:3002/ws\"}, \"encoded\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJ1bmtub3duIiwic3ViIjoxLCJpYXQiOjE2MDk4NzY2OTYsImp0aSI6IjQ2Njk0YzJmLTJhMDMtNGUzZi04NGJkLTdlYzIyZmI1OGMxZiIsImlzcyI6Ii8vMTAuMTEuMS4yMzU6MzAwMCIsImV4cCI6MTYxMzMzMjY5NiwibXF0dCI6IjEwLjExLjEuMjM1IiwiYm90IjoiZGV2aWNlXzIiLCJ2aG9zdCI6Ii8iLCJtcXR0X3dzIjoid3M6Ly8xMC4xMS4xLjIzNTozMDAyL3dzIn0.mdiWPVn0Vp3tYbZfP9P2CRpKd3txjXkfUjvkgALeXAC7OJGSBu_0uIU3ZRakhfu6QaEKX0pP-jKlxvxgNDbYNgwUoqEC8RLrbnGoCx_SmhSf_43TRW2UoKOSo8BFp2D7_0L_zkocW_6VCzwLKo90m09N2VSA4S3wSItRphimEuvK224VSnr5oqyqQ5NfVgkA_usLVpExX6t7-5rF6Lm_VpIm6y-7Z4k6GvaJLH8WIDBsLQrM7STeFNwZGxWuUeblHjY4eyv2DkX7m1s9EN-fCUQzgBzvFUcjemk8rhAfz2A02SqShD2YHa2qidPnjpiJpwXWK9vXYdO2KQsjrsdBrA\"}, \"user\": {\"id\": 1, \"device_id\": 2, \"name\": \"Test\", \"email\": \"test@test.com\", \"created_at\": \"2020-12-14T20: 22: 49.821Z\", \"updated_at\": \"2021-01-05T19: 50: 12.653Z\", \"agreed_to_terms_at\": \"2020-12-14T20: 22: 49.854Z\", \"inactivity_warning_sent_at\": null}}" 6 | fake_token = json.dumps({ 7 | "token": { 8 | "unencoded": { 9 | "bot": "456", 10 | "exp": 1000, 11 | "iat": 900, 12 | "iss": "?", 13 | "jti": "Long string", 14 | "mqtt": "mqtt://my.farm.bot:1883", 15 | "mqtt_ws": "wss://my.farm.bot:1883", 16 | "sub": "device_456", 17 | "vhost": "dfsdfxcsd", 18 | }, 19 | "encoded": "TOPSECRETTOKEN" 20 | } 21 | }) 22 | 23 | 24 | class FakeReturn: 25 | def read(_) -> bytes: 26 | return fake_bytes 27 | 28 | 29 | class FakeFarmbot(fb.Farmbot): 30 | def __init__(self) -> None: 31 | self.password = "drowssap" 32 | self.username = "emanresu" 33 | self.hostname = "tob.mraf.ym" 34 | self.state = None 35 | self.handle_connect = mock.MagicMock() 36 | self.handle_message = mock.MagicMock() 37 | self._handler = fb.StubHandler() 38 | self.read_status = mock.MagicMock() 39 | 40 | 41 | class FakeMQTT(): 42 | def __init__(self) -> None: 43 | self.connect = mock.MagicMock() 44 | self.loop_forever = mock.MagicMock() 45 | self.username_pw_set = mock.MagicMock() 46 | self.subscribe = mock.MagicMock() 47 | 48 | 49 | class FakeMqttMessage(): 50 | def __init__(self, topic: str, payload: str) -> None: 51 | self.topic = topic 52 | self.payload = payload 53 | 54 | 55 | class TestFarmbotToken(): 56 | @mock.patch("farmbot.urlopen", autospec=True, return_value=FakeReturn()) 57 | def test_download_token(_, mock_download_token): 58 | email = "test@test.com" 59 | password = "password123" 60 | server = "http://localhost:3000" 61 | tkn = fb.FarmbotToken.download_token(email=email, 62 | password=password, 63 | server=server) 64 | assert isinstance(tkn, bytes) 65 | assert tkn == fake_bytes 66 | 67 | def test_init(_): 68 | token = fb.FarmbotToken(fake_bytes) 69 | assert(token.bot == "device_2") 70 | assert(token.exp == 1613332696) 71 | assert(token.iat == 1609876696) 72 | assert(token.iss == "//10.11.1.235:3000") 73 | assert(token.jti == "46694c2f-2a03-4e3f-84bd-7ec22fb58c1f") 74 | assert(token.mqtt_ws == "ws://10.11.1.235:3002/ws") 75 | assert(token.mqtt == "10.11.1.235") 76 | assert(token.sub == 1) 77 | assert(token.vhost == "/") 78 | assert(len(token.jwt) == 671) 79 | 80 | 81 | class TestEmptyState(): 82 | def test_empty_state(self): 83 | empty_xyz = {"x": None, "y": None, "z": None} 84 | state = fb.empty_state() 85 | assert(state["configuration"] == {}) 86 | assert(state["informational_settings"] == {}) 87 | assert(state["jobs"] == {}) 88 | assert(state["location_data"]["axis_states"] == empty_xyz) 89 | assert(state["location_data"]["load"] == empty_xyz) 90 | assert(state["location_data"]["position"] == empty_xyz) 91 | assert(state["location_data"]["raw_encoders"] == empty_xyz) 92 | assert(state["location_data"]["scaled_encoders"] == empty_xyz) 93 | assert(state["mcu_params"] == {}) 94 | assert(state["pins"] == {}) 95 | assert(state["user_env"] == {}) 96 | 97 | 98 | class TestErrorResponse(): 99 | def test_error_response(self): 100 | id = "my_id" 101 | errors = ["error1", "error2"] 102 | fake = fb.ErrorResponse(id, errors) 103 | assert(fake.id == id) 104 | assert(fake.errors == errors) 105 | 106 | 107 | class TestOkResponse(): 108 | def test_error_response(self): 109 | id = "my_id" 110 | fake = fb.OkResponse(id) 111 | assert(fake.id == id) 112 | 113 | 114 | class TestFarmbotConnection(): 115 | def test_init(self): 116 | my_farmbot = FakeFarmbot() 117 | connection = fb.FarmbotConnection(my_farmbot) 118 | assert(connection.bot == my_farmbot) 119 | assert(connection.mqtt) 120 | assert(connection.incoming_chan == "bot/emanresu/from_device") 121 | assert(connection.logs_chan == "bot/emanresu/logs") 122 | assert(connection.outgoing_chan == "bot/emanresu/from_clients") 123 | assert(connection.status_chan == "bot/emanresu/status") 124 | expected = ('bot/emanresu/status', 125 | 'bot/emanresu/logs', 126 | 'bot/emanresu/from_device') 127 | assert(connection.channels == expected) 128 | 129 | def test_start_connection(self): 130 | my_farmbot = FakeFarmbot() 131 | client = FakeMQTT() 132 | connection = fb.FarmbotConnection(my_farmbot, client) 133 | connection.start_connection() 134 | client.connect.assert_called_with('tob.mraf.ym', 1883, 60) 135 | client.loop_forever.assert_called() 136 | client.username_pw_set.assert_called_with('emanresu', 'drowssap') 137 | assert(client.on_connect) 138 | assert(client.on_message) 139 | 140 | def test_handle_connect(self): 141 | # https://docs.python.org/3/library/unittest.mock.html 142 | my_farmbot = FakeFarmbot() 143 | my_farmbot._handler.on_connect = mock.MagicMock() 144 | client = FakeMQTT() 145 | connection = fb.FarmbotConnection(my_farmbot, client) 146 | connection.handle_connect(mqtt=client, 147 | userdata=None, 148 | flags=None, 149 | rc=None) 150 | for channel in connection.channels: 151 | client.subscribe.assert_has_calls([mock.call(channel)]) 152 | my_farmbot._handler.on_connect.assert_called_with(my_farmbot, client) 153 | my_farmbot.read_status.assert_called() 154 | 155 | def test_handle_message(self): 156 | my_farmbot = FakeFarmbot() 157 | conn = fb.FarmbotConnection(my_farmbot, FakeMQTT()) 158 | 159 | # == STATUS Message 160 | conn.handle_status = mock.MagicMock() 161 | status_msg = FakeMqttMessage(conn.status_chan, '{}') 162 | conn.handle_message(conn.mqtt, None, status_msg) 163 | conn.handle_status.assert_called_with(status_msg) 164 | 165 | # == LOG Message 166 | conn.handle_log = mock.MagicMock() 167 | log_msg = FakeMqttMessage(conn.logs_chan, '{}') 168 | conn.handle_message(conn.mqtt, None, log_msg) 169 | conn.handle_log.assert_called_with(log_msg) 170 | 171 | # == RPC Message 172 | conn.unpack_response = mock.MagicMock() 173 | rpc_json = '{}' 174 | rpc_msg = FakeMqttMessage(conn.incoming_chan, rpc_json) 175 | conn.handle_message(conn.mqtt, None, rpc_msg) 176 | conn.unpack_response.assert_called_with(rpc_json) 177 | 178 | def test_unpack_response(self): 179 | conn = fb.FarmbotConnection(FakeFarmbot(), FakeMQTT()) 180 | # == OK Response 181 | conn.handle_resp = mock.MagicMock() 182 | rpc_ok = json.dumps({"kind": "rpc_ok", "args": {"label": "123"}}) 183 | conn.unpack_response(rpc_ok) 184 | conn.handle_resp.assert_called_with("123") 185 | 186 | # == ERROR Response 187 | conn.handle_error = mock.MagicMock() 188 | errors = [ 189 | {'kind': 'explanation', 'args': {'message': 'ERROR 1'}}, 190 | {'kind': 'explanation', 'args': {'message': 'ERROR 2'}}, 191 | ] 192 | rpc_err = json.dumps({ 193 | "kind": "rpc_error", 194 | "args": {"label": "456"}, 195 | "body": errors 196 | }) 197 | 198 | conn.unpack_response(rpc_err) 199 | conn.handle_error.assert_called_with("456", errors) 200 | 201 | def test_handle_resp(self): 202 | conn = fb.FarmbotConnection(FakeFarmbot(), FakeMQTT()) 203 | my_mock = mock.MagicMock() 204 | conn.bot._handler.on_response = my_mock 205 | conn.handle_resp("any_label") 206 | args = my_mock.call_args[0] 207 | bot = args[0] 208 | response = args[1] 209 | assert bot == conn.bot 210 | assert response.id == "any_label" 211 | 212 | def test_handle_status(self): 213 | conn = fb.FarmbotConnection(FakeFarmbot(), FakeMQTT()) 214 | status_msg = FakeMqttMessage(conn.status_chan, '{}') 215 | conn.bot._handler.on_change = mock.MagicMock() 216 | conn.handle_status(status_msg) 217 | conn.bot._handler.on_change.assert_called_with(conn.bot, {}) 218 | 219 | def test_handle_log(self): 220 | conn = fb.FarmbotConnection(FakeFarmbot(), FakeMQTT()) 221 | status_msg = FakeMqttMessage(conn.logs_chan, '{}') 222 | conn.bot._handler.on_log = mock.MagicMock() 223 | conn.handle_log(status_msg) 224 | conn.bot._handler.on_log.assert_called_with(conn.bot, {}) 225 | 226 | def test_handle_error(self): 227 | errors = [ 228 | {'kind': 'explanation', 'args': {'message': 'ERROR 1'}}, 229 | {'kind': 'explanation', 'args': {'message': 'ERROR 2'}}, 230 | ] 231 | label = 'fd0ee7c8-6ca8-11eb-9d9d-eba70539ce61' 232 | 233 | conn = fb.FarmbotConnection(FakeFarmbot(), FakeMQTT()) 234 | status_msg = FakeMqttMessage(conn.incoming_chan, '{}') 235 | conn.bot._handler.on_error = mm = mock.MagicMock() 236 | conn.handle_error(label, errors) 237 | args = mm.call_args[0] 238 | actual_bot = args[0] 239 | actual_response = args[1] 240 | assert actual_bot == conn.bot 241 | assert actual_response.id == label 242 | assert actual_response.errors == ['ERROR 1', 'ERROR 2'] 243 | 244 | @mock.patch("farmbot.uuid.uuid1", autospec=True, return_value="FAKE_UUID") 245 | def test_send_rpc(self, _): 246 | mqtt = FakeMQTT() 247 | mqtt.publish = mock.MagicMock() 248 | conn = fb.FarmbotConnection(FakeFarmbot(), mqtt) 249 | # === NON-ARRAY 250 | result = conn.send_rpc({}) 251 | assert result == "FAKE_UUID" 252 | expected_chan = 'bot/emanresu/from_clients' 253 | expected_json = '{"kind": "rpc_request", "args": {"label": "FAKE_UUID"}, "body": [{}]}' 254 | mqtt.publish.assert_called_with(expected_chan, expected_json) 255 | # === ARRAY 256 | result2 = conn.send_rpc([{}]) 257 | assert result2 == "FAKE_UUID" 258 | expected_chan2 = 'bot/emanresu/from_clients' 259 | expected_json2 = '{"kind": "rpc_request", "args": {"label": "FAKE_UUID"}, "body": [{}]}' 260 | mqtt.publish.assert_called_with(expected_chan2, expected_json2) 261 | 262 | fake_token = json.dumps({ 263 | "token": { 264 | "unencoded": { 265 | "bot": "456", 266 | "exp": 1000, 267 | "iat": 900, 268 | "iss": "?", 269 | "jti": "Long string", 270 | "mqtt": "mqtt://my.farm.bot:1883", 271 | "mqtt_ws": "wss://my.farm.bot:1883", 272 | "sub": "device_456", 273 | "vhost": "dfsdfxcsd", 274 | }, 275 | "encoded": "TOPSECRETTOKEN" 276 | } 277 | }) 278 | 279 | @mock.patch("farmbot.FarmbotToken.download_token", autospec=True, return_value=fake_token) 280 | def test_login(self, dl_token): 281 | bot = fb.Farmbot.login("email", "pass") 282 | dl_token.assert_called_with(email='email', 283 | password='pass', 284 | server='https://my.farm.bot') 285 | assert bot.username == "456" 286 | assert bot.password == "TOPSECRETTOKEN" 287 | assert bot.hostname == "mqtt://my.farm.bot:1883" 288 | assert bot.device_id == "device_456" 289 | assert isinstance(bot._handler, fb.StubHandler) 290 | 291 | assert isinstance(bot._connection, fb.FarmbotConnection) 292 | assert bot.state == fb.empty_state() 293 | 294 | def test_position(self): 295 | bot = fb.Farmbot(fake_token) 296 | assert bot.position() == (-0.0, -0.0, -0.0) 297 | 298 | def test__do_cs(self): 299 | fake_rpc = {"kind": "nothing", "args": {}, "body": []} 300 | bot = fb.Farmbot(fake_token) 301 | bot._connection.send_rpc = mock.MagicMock() 302 | bot._do_cs("nothing", {}) 303 | bot._connection.send_rpc.assert_called_with(fake_rpc) 304 | 305 | def test_connect(self): 306 | bot = fb.Farmbot(fake_token) 307 | fake_handler = 'In real life, this would be a class' 308 | bot._connection.start_connection = mock.MagicMock() 309 | bot.connect(fake_handler) 310 | assert bot._handler == fake_handler 311 | bot._connection.start_connection.assert_called() 312 | 313 | def expected_rpc(self, bot, kind, args, body=None): 314 | if not body: 315 | bot._do_cs.assert_called_with(kind, args) 316 | else: 317 | bot._do_cs.assert_called_with(kind, args, body) 318 | 319 | def test_various_rpcs(self): 320 | bot = fb.Farmbot(fake_token) 321 | bot._do_cs = mock.MagicMock() 322 | 323 | bot.send_message("Hello, world!") 324 | self.expected_rpc(bot, 325 | 'send_message', 326 | {'message': 'Hello, world!', 'message_type': 'info'}) 327 | 328 | bot.emergency_lock() 329 | self.expected_rpc(bot, "emergency_lock", {}) 330 | 331 | bot.emergency_unlock() 332 | self.expected_rpc(bot, "emergency_unlock", {}) 333 | 334 | bot.find_home() 335 | self.expected_rpc(bot, "find_home", {"speed": 100, "axis": "all"}) 336 | 337 | bot.find_length() 338 | self.expected_rpc(bot, "calibrate", {"axis": "all"}) 339 | 340 | bot.flash_farmduino("express_k10") 341 | self.expected_rpc(bot, "flash_firmware", {"package": "express_k10"}) 342 | 343 | bot.go_to_home() 344 | self.expected_rpc(bot, "home", {"speed": 100, "axis": "all"}) 345 | 346 | lua_str = "print('Hello, world!')" 347 | bot.lua(lua_str) 348 | self.expected_rpc(bot, "lua", {"lua": lua_str}) 349 | 350 | bot.move_absolute(x=1.2, y=3.4, z=5.6) 351 | self.expected_rpc(bot, 352 | 'move_absolute', 353 | { 354 | 'location': { 355 | 'kind': 'coordinate', 356 | 'args': {'x': 1.2, 'y': 3.4, 'z': 5.6} 357 | }, 358 | 'speed': 100.0, 359 | 'offset': { 360 | 'kind': 'coordinate', 361 | 'args': {'x': 0, 'y': 0, 'z': 0} 362 | } 363 | }) 364 | 365 | bot.move_relative(x=1.2, y=3.4, z=5.6) 366 | self.expected_rpc(bot, 367 | "move_relative", 368 | {"x": 1.2, "y": 3.4, "z": 5.6, "speed": 100}) 369 | 370 | bot.power_off() 371 | self.expected_rpc(bot, "power_off", {}) 372 | 373 | bot.read_status() 374 | self.expected_rpc(bot, "read_status", {}) 375 | 376 | bot.reboot() 377 | self.expected_rpc(bot, "reboot", {"package": "farmbot_os"}) 378 | 379 | bot.reboot_farmduino() 380 | self.expected_rpc(bot, 381 | "reboot", 382 | {"package": "arduino_firmware"}) 383 | 384 | bot.factory_reset() 385 | self.expected_rpc(bot, "factory_reset", {"package": "farmbot_os"}) 386 | 387 | bot.sync() 388 | self.expected_rpc(bot, "sync", {}) 389 | 390 | bot.take_photo() 391 | self.expected_rpc(bot, "take_photo", {}) 392 | 393 | bot.toggle_pin(12) 394 | self.expected_rpc(bot, "toggle_pin", {"pin_number": 12}) 395 | 396 | bot.update_farmbot_os() 397 | self.expected_rpc(bot, "check_updates", {"package": "farmbot_os"}) 398 | 399 | bot.read_pin(21) 400 | self.expected_rpc(bot, 401 | "read_pin", 402 | {'label': 'pin21', 'pin_mode': 0, 'pin_number': 21}) 403 | 404 | bot.write_pin(45, 67) 405 | self.expected_rpc(bot, 406 | "write_pin", 407 | {'pin_mode': 0, 'pin_number': 45, 'pin_value': 67}) 408 | 409 | bot.set_servo_angle(5, 90) 410 | self.expected_rpc(bot, 411 | "set_servo_angle", 412 | {'pin_number': 5, 'pin_value': 90}) 413 | --------------------------------------------------------------------------------