diff --git a/.github/workflows/build_backend.yml b/.github/workflows/build_backend.yml
index 0331b4c8..e78c948a 100644
--- a/.github/workflows/build_backend.yml
+++ b/.github/workflows/build_backend.yml
@@ -8,7 +8,11 @@ on: push
jobs:
test:
runs-on: ubuntu-latest
- container: coderbot/coderbot-ci:3.9-bullseye-slim
+ container:
+ image: ghcr.io/coderbotorg/coderbot-ci:stub-latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GHCR_READ }}
steps:
- uses: actions/checkout@v3 # Checking out the repo
- run: pip install -r docker/stub/requirements.txt
@@ -21,13 +25,16 @@ jobs:
echo "test complete"
- run: |
export PYTHONPATH=./stub:./coderbot:./test
+ export CODERBOT_CLOUD_API_ENDPOINT=http://localhost:5000
python3 coderbot/main.py > coderbot.log &
- sleep 30
+ sleep 60
+ cat coderbot.log
+ curl http://localhost:5000/api/v1/openapi.json
apt-get install -y python3-venv
mkdir -p schemathesis
python3 -m venv schemathesis
. schemathesis/bin/activate
- pip install schemathesis
+ pip install schemathesis==3.24.3
st run --endpoint 'activities' --hypothesis-max-examples=10 --request-timeout=20 http://localhost:5000/api/v1/openapi.json
#st run --endpoint 'media' --hypothesis-max-examples=10 --request-timeout=20 http://localhost:5000/api/v1/openapi.json
st run --endpoint 'control/speak' --hypothesis-max-examples=10 --request-timeout=20 http://localhost:5000/api/v1/openapi.json
diff --git a/coderbot/activity.py b/coderbot/activity.py
index 19e3d102..79c87a81 100644
--- a/coderbot/activity.py
+++ b/coderbot/activity.py
@@ -1,6 +1,16 @@
+import logging
+import uuid
from tinydb import TinyDB, Query
from threading import Lock
+from datetime import datetime
# Programs and Activities databases
+
+ACTIVITY_STATUS_DELETED = "deleted"
+ACTIVITY_STATUS_ACTIVE = "active"
+ACTIVITY_KIND_STOCK = "stock"
+ACTIVITY_KIND_USER = "user"
+
+
class Activities():
_instance = None
@@ -14,11 +24,16 @@ def __init__(self):
self.activities = TinyDB("data/activities.json")
self.query = Query()
self.lock = Lock()
+ self.permanentlyRemoveDeletedActivities()
- def load(self, name, default):
+ def load(self, id, default, active_only=True):
with self.lock:
- if name and default is None:
- activities = self.activities.search(self.query.name == name)
+ if id and default is None:
+ activities = []
+ if active_only:
+ activities = self.activities.search((self.query.id == id) & (self.query.status == ACTIVITY_STATUS_ACTIVE))
+ else:
+ activities = self.activities.search(self.query.id == id)
if len(activities) > 0:
return activities[0]
elif default is not None:
@@ -27,25 +42,45 @@ def load(self, name, default):
return None
return None
- def save(self, name, activity):
+ def save(self, activity):
+ if activity.get("id") is None:
+ activity["id"] = str(uuid.uuid4())
with self.lock:
# if saved activity is "default", reset existing default activity to "non-default"
if activity.get("default", False) is True:
self.activities.update({'default': False})
- if self.activities.search(self.query.name == name) == []:
+ if self.activities.search(self.query.id == activity.get("id")) == []:
self.activities.insert(activity)
else:
- self.activities.update(activity, self.query.name == activity["name"])
+ self.activities.update(activity, self.query.id == activity.get("id"))
+ activity = self.activities.search(self.query.id == activity.get("id"))[0]
+ logging.info("updating/creating activity - id: %s, name: %s", str(activity.get("id")), str(activity.get("name")))
+ return activity
- def delete(self, name):
+ def delete(self, id, logical = True):
with self.lock:
- activities = self.activities.search(self.query.name == name)
+ activities = self.activities.search(self.query.id == id)
if len(activities) > 0:
activity = activities[0]
if activity.get("default", False) is True:
- self.activities.update({'default': True}, self.query.stock == True)
- self.activities.remove(self.query.name == activity["name"])
+ self.activities.update({'default': True}, self.query.kind == ACTIVITY_KIND_STOCK)
+ if logical:
+ activity["status"] = ACTIVITY_STATUS_DELETED
+ activity["modified"] = datetime.now().isoformat()
+ self.activities.update(activity, self.query.id == id)
+ else:
+ self.activities.remove(self.query.id == id)
- def list(self):
+ def permanentlyRemoveDeletedActivities(self):
+ for a in self.list(active_only=False):
+ logging.info("checking: " + a["id"])
+ if a["status"] == ACTIVITY_STATUS_DELETED:
+ logging.info("deleting: " + a["name"])
+ self.delete(a["id"], logical=False)
+
+ def list(self, active_only = True):
with self.lock:
- return self.activities.all()
+ if active_only:
+ return self.activities.search(self.query.status == ACTIVITY_STATUS_ACTIVE)
+ else:
+ return self.activities.all()
diff --git a/coderbot/api.py b/coderbot/api.py
index 88052aff..c150f44e 100644
--- a/coderbot/api.py
+++ b/coderbot/api.py
@@ -7,6 +7,7 @@
import os
import subprocess
import urllib
+from datetime import datetime
import connexion
import picamera
@@ -21,25 +22,14 @@
from runtime_test import run_test
from musicPackages import MusicPackageManager
from program import Program, ProgramEngine
+from motion import Motion
+from cloud.sync import CloudManager
from balena import Balena
from coderbot import CoderBot
BUTTON_PIN = 16
-config = Config.read()
-bot = CoderBot.get_instance(motor_trim_factor=float(config.get('move_motor_trim', 1.0)),
- motor_max_power=int(config.get('motor_max_power', 100)),
- motor_min_power=int(config.get('motor_min_power', 0)),
- hw_version=config.get('hardware_version'),
- pid_params=(float(config.get('pid_kp', 1.0)),
- float(config.get('pid_kd', 0.1)),
- float(config.get('pid_ki', 0.01)),
- float(config.get('pid_max_speed', 200)),
- float(config.get('pid_sample_time', 0.01))))
-audio_device = Audio.get_instance()
-cam = Camera.get_instance()
-
def get_serial():
"""
Extract serial from cpuinfo file
@@ -77,7 +67,7 @@ def get_status():
internet_status = False
try:
- urllib.request.urlopen("https://coderbot.org")
+ urllib.request.urlopen("https://coderCoderBot.get_instance().org")
internet_status = True
except Exception:
pass
@@ -114,48 +104,48 @@ def get_info():
## Robot control
def stop():
- bot.stop()
- return 200
+ CoderBot.get_instance().stop()
+ return {}
def move(body):
speed=body.get("speed")
elapse=body.get("elapse")
distance=body.get("distance")
if (speed is None or speed == 0) or (elapse is not None and distance is not None):
- return 400
- bot.move(speed=speed, elapse=elapse, distance=distance)
- return 200
+ return None, 400
+ CoderBot.get_instance().move(speed=speed, elapse=elapse, distance=distance)
+ return {}
def turn(body):
speed=body.get("speed")
elapse=body.get("elapse")
distance=body.get("distance")
if (speed is None or speed == 0) or (elapse is not None and distance is not None):
- return 400
- bot.turn(speed=speed, elapse=elapse, distance=distance)
- return 200
+ return None, 400
+ CoderBot.get_instance().turn(speed=speed, elapse=elapse, distance=distance)
+ return {}
def takePhoto():
try:
- cam.photo_take()
- audio_device.say(config.get("sound_shutter"))
- return 200
+ Camera.get_instance().photo_take()
+ Audio.get_instance().say(settings.get("sound_shutter"))
+ return {}
except Exception as e:
logging.warning("Error: %s", e)
def recVideo():
try:
- cam.video_rec()
- audio_device.say(config.get("sound_shutter"))
- return 200
+ Camera.get_instance().video_rec()
+ Audio.get_instance().say(settings.get("sound_shutter"))
+ return {}
except Exception as e:
logging.warning("Error: %s", e)
def stopVideo():
try:
- cam.video_stop()
- audio_device.say(config.get("sound_shutter"))
- return 200
+ Camera.get_instance().video_stop()
+ Audio.get_instance().say(settings.get("sound_shutter"))
+ return {}
except Exception as e:
logging.warning("Error: %s", e)
@@ -163,25 +153,25 @@ def speak(body):
text = body.get("text", "")
locale = body.get("locale", "")
logging.debug("say: " + text + " in: " + locale)
- audio_device.say(text, locale)
- return 200
+ Audio.get_instance().say(text, locale)
+ return {}
def reset():
Balena.get_instance().purge()
- return 200
+ return {}
def halt():
- audio_device.say(what=config.get("sound_stop"))
+ Audio.get_instance().say(what=settings.get("sound_stop"))
Balena.get_instance().shutdown()
- return 200
+ return {}
def restart():
Balena.get_instance().restart()
def reboot():
- audio_device.say(what=config.get("sound_stop"))
+ Audio.get_instance().say(what=settings.get("sound_stop"))
Balena.get_instance().reboot()
- return 200
+ return {}
def video_stream(a_cam):
while True:
@@ -198,7 +188,7 @@ def streamVideo():
h.add('Age', 0)
h.add('Cache-Control', 'no-cache, private')
h.add('Pragma', 'no-cache')
- return Response(video_stream(cam), headers=h, mimetype="multipart/x-mixed-replace; boundary=--BOUNDARYSTRING")
+ return Response(video_stream(Camera.get_instance()), headers=h, mimetype="multipart/x-mixed-replace; boundary=--BOUNDARYSTRING")
except Exception:
pass
@@ -206,31 +196,31 @@ def listPhotos():
"""
Expose the list of taken photos
"""
- return cam.get_photo_list()
+ return Camera.get_instance().get_photo_list()
def getPhoto(name):
mimetype = {'jpg': 'image/jpeg', 'mp4': 'video/mp4'}
try:
- media_file = cam.get_photo_file(name)
+ media_file = Camera.get_instance().get_photo_file(name)
return send_file(media_file, mimetype=mimetype.get(name[:-3], 'image/jpeg'), max_age=0)
except picamera.exc.PiCameraError as e:
logging.error("Error: %s", str(e))
- return 503
+ return {"exception": str(e)}, 503
except FileNotFoundError:
- return 404
+ return None, 404
def savePhoto(name, body):
try:
- cam.update_photo({"name": name, "tag": body.get("tag")})
+ Camera.get_instance().update_photo({"name": name, "tag": body.get("tag")})
except FileNotFoundError:
- return 404
+ return None, 404
def deletePhoto(name):
logging.debug("photo delete")
try:
- cam.delete_photo(name)
+ Camera.get_instance().delete_photo(name)
except FileNotFoundError:
- return 404
+ return None, 404
def restoreSettings():
Config.restore()
@@ -241,14 +231,14 @@ def loadSettings():
def saveSettings(body):
Config.write(body)
- return 200
+ return Config.write(body)
def updateFromPackage():
os.system('sudo bash /home/pi/clean-update.sh')
file_to_upload = connexion.request.files['file_to_upload']
file_to_upload.save(os.path.join('/home/pi/', 'update.tar'))
os.system('sudo reboot')
- return 200
+ return {}
def listMusicPackages():
"""
@@ -283,45 +273,67 @@ def deleteMusicPackage(name):
"""
musicPkg = MusicPackageManager.get_instance()
musicPkg.deletePackage(name)
- return 200
+ return {}
## Programs
-def saveProgram(name, body):
+def saveNewProgram(body):
overwrite = body.get("overwrite")
- existing_program = prog_engine.load(name)
+ name = body["name"]
+ existing_program = prog_engine.load_by_name(name)
logging.info("saving - name: %s, body: %s", name, str(existing_program))
if existing_program is not None and not overwrite:
return "askOverwrite"
- elif existing_program is not None and existing_program.is_default() == True:
+ elif existing_program is not None and existing_program.is_stock() == True:
+ return "defaultCannotOverwrite", 400
+ program = Program(name=body.get("name"), code=body.get("code"), dom_code=body.get("dom_code"), modified=datetime.now(), status="active")
+ program_db_entry = prog_engine.save(program)
+ return program_db_entry
+
+def saveProgram(id, body):
+ overwrite = body.get("overwrite")
+ name = body.get("name", None)
+ existing_program = prog_engine.load(id)
+ if existing_program is None:
+ return {}, 404
+ logging.info("saving - id: %s - name: %s - existing: %s", id, name, str(existing_program is not None))
+ if existing_program is not None and existing_program.is_stock() == True:
return "defaultCannotOverwrite", 400
- program = Program(name=body.get("name"), code=body.get("code"), dom_code=body.get("dom_code"))
+ program = Program(
+ id=existing_program._id,
+ name=existing_program._name,
+ code=body.get("code"),
+ dom_code=body.get("dom_code"),
+ modified=datetime.now(),
+ status="active")
prog_engine.save(program)
- return 200
+ return program.as_dict()
-def loadProgram(name):
- existing_program = prog_engine.load(name)
+def loadProgram(id):
+ existing_program = prog_engine.load(id)
if existing_program:
return existing_program.as_dict(), 200
else:
- return 404
+ return None, 404
-def deleteProgram(name):
- prog_engine.delete(name)
+def deleteProgram(id):
+ prog_engine.delete(id, logical=True)
def listPrograms():
- return prog_engine.prog_list()
+ return prog_engine.prog_list(active_only=True)
-def runProgram(name, body):
+def runProgram(id):
"""
Execute the given program
"""
logging.debug("program_exec")
- code = body.get('code')
- prog = prog_engine.create(name, code)
- return prog.execute()
+ prog = prog_engine.load(id)
+ if prog is not None:
+ return prog.execute()
+ else:
+ return {}, 404
-def stopProgram(name):
+def stopProgram(id):
"""
Stop the program execution
"""
@@ -331,7 +343,7 @@ def stopProgram(name):
prog.stop()
return "ok"
-def statusProgram(name):
+def statusProgram(id):
"""
Expose the program status
"""
@@ -344,19 +356,20 @@ def statusProgram(name):
## Activities
-def saveActivity(name, body):
+def saveActivity(id, body):
activity = body
- activities.save(activity.get("name"), activity)
+ activity["id"] = id
+ return activities.save(activity)
def saveAsNewActivity(body):
activity = body
- activities.save(activity.get("name"), activity)
+ return activities.save(activity)
-def loadActivity(name=None, default=None):
- return activities.load(name, default)
+def loadActivity(id=None, default=None):
+ return activities.load(id, default)
-def deleteActivity(name):
- activities.delete(name), 200
+def deleteActivity(id):
+ activities.delete(id), 200
def listActivities():
return activities.list()
@@ -387,7 +400,7 @@ def trainCNNModel(body):
cnn.train_new_model(model_name=body.get("model_name"),
architecture=body.get("architecture"),
image_tags=body.get("image_tags"),
- photos_meta=cam.get_photo_list(),
+ photos_meta=Camera.get_instance().get_photo_list(),
training_steps=body.get("training_steps"),
learning_rate=body.get("learning_rate"))
@@ -403,4 +416,30 @@ def deleteCNNModel(name):
cnn = CNNManager.get_instance()
model_status = cnn.delete_model(model_name=name)
- return model_status
\ No newline at end of file
+ return model_status
+
+def cloudSyncRequest():
+ CloudManager.get_instance().sync()
+ return {}
+
+def cloudSyncStatus():
+ return CloudManager.get_instance().sync_status()
+
+def cloudRegistrationRequest(body):
+ CloudManager.get_instance().register(body)
+ return {}
+
+def cloudRegistrationDelete():
+ CloudManager.get_instance().unregister()
+ return {}
+
+def cloudRegistrationStatus():
+ registration = Config.read().get("cloud").get('registration', {})
+ return {
+ "registered": CloudManager.get_instance().registration_status(),
+ "name": registration.get('name', ""),
+ "description": registration.get('description', ""),
+ "org_id": registration.get('org_id', ""),
+ "org_name": registration.get('org_name', ""),
+ "org_description": registration.get('org_description', "")
+ }
diff --git a/coderbot/audio.py b/coderbot/audio.py
index ed1a469e..f67f99ff 100644
--- a/coderbot/audio.py
+++ b/coderbot/audio.py
@@ -46,12 +46,12 @@ class Audio:
_instance = None
@classmethod
- def get_instance(cls):
+ def get_instance(cls, settings=None):
if cls._instance is None:
- cls._instance = Audio()
+ cls._instance = Audio(settings)
return cls._instance
- def __init__(self):
+ def __init__(self, settings):
self.pa = pyaudio.PyAudio()
try:
self.stream_in = self.MicrophoneStream(FORMAT, RATE, CHUNK)
diff --git a/coderbot/camera.py b/coderbot/camera.py
index 8866b9fd..5247206e 100644
--- a/coderbot/camera.py
+++ b/coderbot/camera.py
@@ -52,30 +52,30 @@ class Camera(object):
_instance = None
@classmethod
- def get_instance(cls):
+ def get_instance(cls, settings=None):
if cls._instance is None:
- cls._instance = Camera()
+ cls._instance = Camera(settings)
#cls._instance.start()
return cls._instance
- def __init__(self):
+ def __init__(self, settings):
logging.info("starting camera")
cam_props = {"width":640, "height":512,
- "cv_image_factor": config.Config.get().get("cv_image_factor"),
- "exposure_mode": config.Config.get().get("camera_exposure_mode"),
- "framerate": config.Config.get().get("camera_framerate"),
- "bitrate": config.Config.get().get("camera_jpeg_bitrate"),
- "jpeg_quality": int(config.Config.get().get("camera_jpeg_quality"))}
+ "cv_image_factor": settings.get("cv_image_factor"),
+ "exposure_mode": settings.get("camera_exposure_mode"),
+ "framerate": settings.get("camera_framerate"),
+ "bitrate": settings.get("camera_jpeg_bitrate"),
+ "jpeg_quality": int(settings.get("camera_jpeg_quality"))}
self._camera = camera.Camera(props=cam_props)
self.recording = False
self.video_start_time = time.time() + 8640000
self._image_time = 0
- self._cv_image_factor = int(config.Config.get().get("cv_image_factor", 4))
- self._image_refresh_timeout = float(config.Config.get().get("camera_refresh_timeout", 0.1))
- self._color_object_size_min = int(config.Config.get().get("camera_color_object_size_min", 80)) / (self._cv_image_factor * self._cv_image_factor)
- self._color_object_size_max = int(config.Config.get().get("camera_color_object_size_max", 32000)) / (self._cv_image_factor * self._cv_image_factor)
- self._path_object_size_min = int(config.Config.get().get("camera_path_object_size_min", 80)) / (self._cv_image_factor * self._cv_image_factor)
- self._path_object_size_max = int(config.Config.get().get("camera_path_object_size_max", 32000)) / (self._cv_image_factor * self._cv_image_factor)
+ self._cv_image_factor = int(settings.get("cv_image_factor", 4))
+ self._image_refresh_timeout = float(settings.get("camera_refresh_timeout", 0.1))
+ self._color_object_size_min = int(settings.get("camera_color_object_size_min", 80)) / (self._cv_image_factor * self._cv_image_factor)
+ self._color_object_size_max = int(settings.get("camera_color_object_size_max", 32000)) / (self._cv_image_factor * self._cv_image_factor)
+ self._path_object_size_min = int(settings.get("camera_path_object_size_min", 80)) / (self._cv_image_factor * self._cv_image_factor)
+ self._path_object_size_max = int(settings.get("camera_path_object_size_max", 32000)) / (self._cv_image_factor * self._cv_image_factor)
self.load_photo_metadata()
if not self._photos:
self._photos = []
@@ -86,7 +86,7 @@ def __init__(self):
self.save_photo_metadata()
self._cnn_classifiers = {}
- cnn_model = config.Config.get().get("cnn_default_model", "")
+ cnn_model = settings.get("cnn_default_model", "")
if cnn_model != "":
try:
self._cnn_classifiers[cnn_model] = CNNManager.get_instance().load_model(cnn_model)
diff --git a/coderbot/cloud/sync.py b/coderbot/cloud/sync.py
new file mode 100644
index 00000000..93ed8779
--- /dev/null
+++ b/coderbot/cloud/sync.py
@@ -0,0 +1,460 @@
+# Sync CoderBot configuration with remote Cloud configuration
+#
+# For all configuration entities (settings, activities, programs):
+# check sync mode (upstream, downstream, both)
+# if up:
+# compare entity, if different, push changes
+# if down:
+# compare entity, if different, pull changes
+# if both:
+# compare entity, if different, take most recent and push/pull changes
+#
+
+import os
+import threading
+from datetime import datetime, timezone
+import logging
+import json
+from time import sleep
+
+from config import Config
+from activity import Activities, ACTIVITY_KIND_USER, ACTIVITY_KIND_STOCK, ACTIVITY_STATUS_ACTIVE, ACTIVITY_STATUS_DELETED
+from program import ProgramEngine
+import program
+import activity
+
+import cloud_api_robot_client
+from cloud_api_robot_client.apis.tags import robot_sync_api
+from cloud_api_robot_client.model.activity import Activity
+from cloud_api_robot_client.model.program import Program
+from cloud_api_robot_client.model.robot_data import RobotData
+from cloud_api_robot_client.model.setting import Setting
+from cloud_api_robot_client.model.robot_register_data import RobotRegisterData
+from cloud_api_robot_client.model.robot_credentials import RobotCredentials
+
+SYNC_UPSTREAM = 'u'
+SYNC_DOWNSTREAM = 'd'
+SYNC_BIDIRECTIONAL = 'b'
+SYNC_DISABLED = 'n'
+
+ENTITY_KIND_USER = "user"
+ENTITY_KIND_STOCK = "stock"
+
+AUTH_FILE = "data/auth.json"
+
+class CloudManager(threading.Thread):
+ _instance = None
+
+ _auth = {}
+
+ @classmethod
+ def get_auth(cls):
+ return cls._auth
+
+ @classmethod
+ def read_auth(cls):
+ with open(AUTH_FILE, 'r') as f:
+ cls._auth = json.load(f)
+ f.close()
+ return cls._auth
+
+ @classmethod
+ def write_auth(cls, auth):
+ cls._auth = auth
+ f = open(AUTH_FILE, 'w')
+ json.dump(cls._auth, f)
+ return cls._auth
+
+ @classmethod
+ def get_instance(cls):
+ if cls._instance is None:
+ cls._instance = CloudManager()
+ return cls._instance
+
+ def __init__(self):
+ self._syncing = False
+ self._sync_status = {
+ "settings": "",
+ "activities": "",
+ "programs": ""
+ }
+ threading.Thread.__init__(self)
+ # Defining the host is optional and defaults to https://api.coderbot.org/api/v1
+ # See configuration.py for a list of all supported configuration parameters.
+ self.configuration = cloud_api_robot_client.Configuration(
+ host = os.getenv("CODERBOT_CLOUD_API_ENDPOINT") + "/api/v1",
+ )
+ try:
+ self.read_auth()
+ except FileNotFoundError:
+ self.write_auth({})
+ self.start()
+
+ def register(self, registration_request):
+ logging.info("register.check.token")
+ token = self.get_auth().get("token")
+ if token:
+ logging.warn("register.check.token_already_there")
+ return
+ reg_otp = registration_request.get("otp")
+ try:
+ self._sync_status["registration"] = "registering"
+ logging.info("register.get_token")
+ with cloud_api_robot_client.ApiClient(self.configuration) as api_client:
+ api_instance = robot_sync_api.RobotSyncApi(api_client)
+ body = RobotRegisterData(
+ otp=reg_otp,
+ )
+ api_response = api_instance.register_robot(body=body)
+ logging.info(api_response.body)
+ token = api_response.body.get("token")
+ self.write_auth({"token":token})
+ self._sync_status["registration"] = "registered"
+ except cloud_api_robot_client.ApiException as e:
+ logging.warn("Exception when calling register_robot RobotSyncApi: %s\n" % e)
+ raise
+
+ def unregister(self):
+ self.write_auth({ "token": None })
+
+ def registration_status(self):
+ return self._auth.get("token") is not None
+
+ def run(self):
+ while(True):
+ sync_period = int(Config.read().get("cloud").get("sync_period", "60"))
+ self.sync()
+ sleep(sync_period)
+
+ def syncing(self):
+ return self._syncing
+
+ def sync_status(self):
+ return self._sync_status
+
+ def sync(self):
+ if self._syncing == True:
+ return
+ self._syncing = True
+
+ settings = Config.read()
+ cloud_settings = settings.get("cloud")
+ logging.info("run.sync.begin")
+ sync_modes = cloud_settings.get("sync_modes", {"settings": "n", "activities": "n", "programs": "n"})
+
+ token = self.get_auth().get("token")
+ if token is not None:
+ try:
+ self.configuration.access_token = token
+
+ # Enter a context with an instance of the API client
+ with cloud_api_robot_client.ApiClient(self.configuration) as api_client:
+ # Create an instance of the API class
+ api_instance = robot_sync_api.RobotSyncApi(api_client)
+
+ self.sync_settings(api_instance, sync_modes["settings"])
+ self.sync_activities(api_instance, sync_modes["activities"])
+ self.sync_programs(api_instance, sync_modes["programs"])
+ except Exception as e:
+ logging.warn("run.sync.api_not_available: " + str(e))
+ raise e
+
+ logging.info("run.sync.end")
+ self._syncing = False
+
+ # def get_token_or_register(self, cloud_settings):
+ # logging.info("run.check.token")
+ # token = self.get_auth().get("token")
+ # reg_otp = cloud_settings.get("reg_otp")
+ # try:
+ # if token is None and reg_otp is not None:
+ # self._sync_status["registration"] = "registering"
+ # logging.info("run.get_token_or_register.get_token")
+ # with cloud_api_robßot_client.ApiClient(self.configuration) as api_client:
+ # api_instance = robot_sync_api.RobotSyncApi(api_client)
+ # body = RobotRegisterData(
+ # otp=reg_otp,
+ # )
+ # api_response = api_instance.register_robot(body=body)
+ # logging.info(api_response.body)
+ # token = api_response.body.get("token")
+ # self.write_auth({"token":token})
+ # self._sync_status["registration"] = "registered"
+ # return token
+ # except cloud_api_robot_client.ApiException as e:
+ # logging.warn("Exception when calling register_robot RobotSyncApi: %s\n" % e)
+
+ # sync settings
+ def sync_settings(self, api_instance, sync_mode):
+ # Sync settings is different from syncing other entities, like activities and programs, since there can only
+ # be a single entity for each robot (both device and cloud twin).
+ # So the algo is simpler and favorites the "stock" entity over the "user" entity, if available.
+ try:
+ self._sync_status["settings"] = "syncing"
+ api_response = api_instance.get_robot_setting()
+ cloud_setting_object = api_response.body
+ cloud_setting = json.loads(cloud_setting_object.get('data'))
+ # sync only the "settings" and "cloud" sections, do not sync "network"
+ config = Config.read()
+ local_setting = {
+ "settings": config.get("settings")
+ }
+ local_most_recent = datetime.fromisoformat(cloud_setting_object.get("modified")).timestamp() < Config.modified()
+ cloud_kind_user = cloud_setting_object.get("kind") == ENTITY_KIND_USER
+ # logging.info(f"cloud_kind_user: {cloud_kind_user}, cloud_setting != local_setting: {cloud_setting != local_setting}, local_most_recent: {local_most_recent}, sync_mode in [SYNC_UPSTREAM, SYNC_BIDIRECTIONAL]: {sync_mode in [SYNC_UPSTREAM, SYNC_BIDIRECTIONAL]}")
+ # logging.info("settings.syncing: " + cloud_setting_object.get("id", "") + " name: " + cloud_setting_object.get("name", ""))
+ if cloud_kind_user and cloud_setting != local_setting and local_most_recent and sync_mode in [SYNC_UPSTREAM, SYNC_BIDIRECTIONAL]:
+ body = Setting(
+ id = cloud_setting_object.get('id'),
+ org_id = cloud_setting_object.get('org_id'),
+ name = cloud_setting_object.get('name'),
+ description = cloud_setting_object.get('description'),
+ data = json.dumps(local_setting),
+ kind = cloud_setting_object.get("kind"),
+ modified = datetime.now().isoformat(),
+ status = cloud_setting_object.get('status'),
+ )
+ api_response = api_instance.set_robot_setting(body)
+ logging.info("settings.upstream")
+ elif cloud_setting != local_setting and sync_mode in [SYNC_DOWNSTREAM, SYNC_BIDIRECTIONAL]: # setting, down
+ config["settings"] = cloud_setting["settings"]
+ Config.write(config)
+ logging.info("settings.downstream")
+ self._sync_status["settings"] = "synced"
+ except cloud_api_robot_client.ApiException as e:
+ logging.warn("Exception when calling settings RobotSyncApi: %s\n", e)
+ self._sync_status["registration"] = "failed"
+
+ def sync_activities(self, api_instance, sync_mode):
+ try:
+ self._sync_status["activities"] = "syncing"
+ activities_local_user = list()
+ activities_local_stock = list()
+ activities_local_to_be_deleted = list()
+ activities_local_map = {}
+ for a in Activities.get_instance().list(active_only=False):
+ if a.get("kind") == ACTIVITY_KIND_USER:
+ if a.get("status") == ACTIVITY_STATUS_ACTIVE:
+ activities_local_user.append(a)
+ if a.get("id") is not None:
+ activities_local_map[a.get("id")] = a
+ elif a.get("status") == ACTIVITY_STATUS_DELETED:
+ activities_local_to_be_deleted.append(a)
+ else:
+ activities_local_stock.append(a)
+ if a.get("id") is not None:
+ activities_local_map[a.get("id")] = a
+ # Get robot activities
+ api_response = api_instance.get_robot_activities()
+ cloud_activities = api_response.body
+ # cloud activities
+ activities_cloud_map = {}
+ for a in cloud_activities:
+ logging.info("cloud_activities %s, %s", str(a.get("status")), a.get("id"))
+ if a.get("status") == ACTIVITY_STATUS_ACTIVE:
+ activities_cloud_map[a.get("id")] = a
+
+ # loop through local
+ for al in activities_local_user:
+ logging.info("activities.syncing: " + str(al.get("id")) + " name: " + str(al.get("name")))
+ ac = activities_cloud_map.get(al.get("id"))
+ ac_al_equals = (ac is not None and ac.get("id") == al.get("id"))
+
+ if ac is not None and not ac_al_equals:
+ al["modified"] = al.get("modified", datetime.now(tz=timezone.utc).isoformat())
+ local_activity_more_recent = datetime.fromisoformat(ac.get("modified")).timestamp() < datetime.fromisoformat(al.get("modified")).timestamp()
+ if sync_mode == SYNC_UPSTREAM or (local_activity_more_recent and sync_mode == SYNC_BIDIRECTIONAL):
+ ac["data"] = al.get("data")
+ ac["modified"] = al.get("modified")
+ body = Activity(
+ id=ac.get("id"),
+ org_id=ac.get("org_id"),
+ name=al.get("name"),
+ description=al.get("description"),
+ data=json.dumps(al.get("data")),
+ kind = al.get("kind", ENTITY_KIND_STOCK),
+ modified=al.get("modified").isoformat(),
+ status='active',
+ )
+ #logging.info("run.activities.cloud.saving")
+ api_response = api_instance.set_robot_activity(ac.get("id"), body)
+ logging.info("activities.update.upstream: " + al.get("name"))
+ elif sync_mode == "d" or (not local_activity_more_recent and sync_mode == SYNC_BIDIRECTIONAL):
+ al["data"] = ac.get("data")
+ al["modified"] = ac.get("modified")
+ Activities.get_instance().save(al)
+ logging.info("activities.update.downstream: " + al.get("id"))
+ elif ac is None and sync_mode in [SYNC_UPSTREAM, SYNC_BIDIRECTIONAL]:
+ body = Activity(
+ id=al.get("id"),
+ org_id="",
+ name=al.get("name"),
+ description=al.get("description"),
+ data=json.dumps(al),
+ kind=al.get("kind", ENTITY_KIND_STOCK),
+ modified=al.get("modified", datetime.now(tz=timezone.utc).isoformat()),
+ status="active",
+ )
+ logging.info("activities.create.upstream - id %s, name: %s", al.get("id"), al.get("name"))
+ api_response = api_instance.create_robot_activity(body=body)
+ al["id"] = api_response.body["id"]
+ al["org_id"] = api_response.body["org_id"]
+ Activities.get_instance().save(al)
+ #logging.info("activities.create.upstream: " + al.get("name"))
+ elif ac is None and sync_mode in [SYNC_DOWNSTREAM]:
+ Activities.get_instance().delete(al.get("id"))
+ logging.info("activities.delete.downstream: " + al.get("name"))
+
+ for k, ac in activities_cloud_map.items():
+ if activities_local_map.get(k) is None and sync_mode in [SYNC_DOWNSTREAM, SYNC_BIDIRECTIONAL]:
+ logging.info("activities.create.downstream: " + ac.get("name"))
+ activity = json.loads(ac.get("data"))
+ activity["id"] = ac.get("id")
+ activity["org_id"] = ac.get("org_id")
+ activity["name"] = ac.get("name")
+ activity["description"] = ac.get("description")
+ activity["kind"] = ac.get("kind")
+ activity["status"] = ac.get("status")
+ Activities.get_instance().save(activity)
+
+ # manage local user activities to be deleted locally and upstream
+ for al in activities_local_to_be_deleted:
+ if al.get("id") is not None:
+ logging.info("activities.delete.upstream: " + al.get("name"))
+ api_response = api_instance.delete_robot_activity(path_params={"activity_id":al.get("id")})
+ # delete locally permanently
+ Activities.get_instance().delete(al.get("id"), logical=False)
+
+ # manage local stock activities to be deleted locally
+ # for al in activities_local_stock:
+ # # logging.info("activities.check.stock.locally: " + al.get("name") + " id: " + str(al.get("id")))
+ # if al.get("id") is not None and activities_cloud_map.get(al.get("id")) is None:
+ # logging.info("activities.delete.stock.locally: " + al.get("name"))
+ # # delete locally permanently
+ # Activities.get_instance().delete(al.get("id"), logical=False)
+
+ self._sync_status["activities"] = "synced"
+ except cloud_api_robot_client.ApiException as e:
+ logging.warn("Exception when calling activities RobotSyncApi: %s\n" % e)
+ self._sync_status["activities"] = "failed"
+
+ def sync_programs(self, api_instance, sync_mode):
+ try:
+ self._sync_status["programs"] = "syncing"
+ programs_local_user = list()
+ programs_local_stock = list()
+ programs_local_map = {}
+ programs_local_to_be_deleted = list()
+ for p in ProgramEngine.get_instance().prog_list(active_only=False):
+ if p.get("kind") == program.PROGRAM_KIND_USER:
+ if p.get("status") == program.PROGRAM_STATUS_ACTIVE:
+ programs_local_user.append(p)
+ if p.get("id") is not None:
+ programs_local_map[p.get("id")] = p
+ elif p.get("status") == program.PROGRAM_STATUS_DELETED:
+ programs_local_to_be_deleted.append(p)
+ else:
+ programs_local_stock.append(p)
+ if p.get("id") is not None:
+ programs_local_map[p.get("id")] = p
+
+ # Get cloud programs
+ api_response = api_instance.get_robot_programs()
+ cloud_programs = api_response.body
+ # cloud programs in a map id : program
+ programs_cloud_map = {} # programs_cloud_map
+ for p in cloud_programs:
+ if p.get("status") == program.PROGRAM_STATUS_ACTIVE:
+ programs_cloud_map[p.get("id")] = p
+
+ # sync local user programs
+ # manage user programs present locally and in "active" status
+ for pl in programs_local_user:
+ pc = programs_cloud_map.get(pl.get("id"))
+ pc_pl_equals = (pc is not None and
+ pc.get("id") == pl.get("id"))
+ logging.info("programs.syncing: " + str(pl.get("id")) + " name: " + pl.get("name"))
+
+ if pc is not None and not pc_pl_equals:
+ # cloud program exists and is different from local
+ pl["modified"] = pl.get("modified", datetime.now(tz=timezone.utc).isoformat())
+ local_program_more_recent = datetime.fromisoformat(pc.get("modified")).timestamp() < datetime.fromisoformat(pl.get("modified")).timestamp()
+ if sync_mode == SYNC_UPSTREAM or (local_program_more_recent and sync_mode == SYNC_BIDIRECTIONAL) and not to_be_deleted:
+ # cloud program exists and is less recent
+ pc["data"] = pl.get("data")
+ pc["modified"] = pl.get("modified")
+ body = Program(
+ id=pc.get("id"),
+ org_id=pc.get("org_id"),
+ name=pl.get("name"),
+ description=pl.get("description"),
+ code=pl.get("code"),
+ dom_code=pl.get("dom_code"),
+ kind=pl.get("kind"),
+ modified=pl.get("modified").isoformat(),
+ status='active',
+ )
+ #logging.info("run.activities.cloud.saving")
+ api_response = api_instance.set_robot_program(pc.get("id"), body)
+ logging.info("programs.update.upstream: " + pl.get("name"))
+ elif sync_mode == SYNC_DOWNSTREAM or (not local_program_more_recent and sync_mode == SYNC_BIDIRECTIONAL):
+ # cloud program exists and is more recent
+ pl["data"] = pc.get("data")
+ pl["modified"] = pc.get("modified")
+ ProgramEngine.get_instance().save(program.Program.from_dict(pl))
+ logging.info("programs.update.downstream: " + pl.get("name"))
+ elif pc is None and sync_mode in [SYNC_UPSTREAM, SYNC_BIDIRECTIONAL]:
+ # cloud program does not exist
+ body = Program(
+ id=pl.get("id"),
+ org_id="",
+ name=pl.get("name"),
+ description=pl.get("description", ""),
+ code=pl.get("code"),
+ dom_code=pl.get("dom_code"),
+ kind=pl.get("kind"),
+ modified=pl.get("modified", datetime.now(tz=timezone.utc).isoformat()),
+ status="active",
+ )
+ api_response = api_instance.create_robot_program(body=body)
+ pl["id"] = api_response.body["id"]
+ pl["org_id"] = api_response.body["org_id"]
+ ProgramEngine.get_instance().save(program.Program.from_dict(pl))
+ logging.info("programs.create.upstream: " + pl.get("name"))
+ elif pc is None and sync_mode in [SYNC_DOWNSTREAM]:
+ # cloud program does not exist, delete locally since sync_mode is downstream
+ ProgramEngine.get_instance().delete(pl.get("id"))
+ logging.info("programs.delete.downstream: " + pl.get("name"))
+
+ # manage cloud programs not present locally in "active" status
+ for k, pc in programs_cloud_map.items():
+ if programs_local_map.get(k) is None and sync_mode in [SYNC_DOWNSTREAM, SYNC_BIDIRECTIONAL]:
+ pl = program.Program(name=pc.get("name"),
+ code=pc.get("code"),
+ dom_code=pc.get("dom_code"),
+ kind=pc.get("kind"),
+ id=pc.get("id"),
+ modified=datetime.fromisoformat(pc.get("modified")),
+ status=pc.get("status"))
+ ProgramEngine.get_instance().save(pl)
+ logging.info("programs.create.downstream: " + pc.get("name"))
+
+ # manage local user programs to be deleted locally and upstream
+ for pl in programs_local_to_be_deleted:
+ if pl.get("id") is not None:
+ logging.info("programs.delete.upstream: " + pl.get("name"))
+ api_response = api_instance.delete_robot_program(path_params={"program_id":pl.get("id")})
+ # delete locally permanently
+ ProgramEngine.get_instance().delete(pl.get("id"), logical=False)
+
+ # manage local stock programs to be deleted locally
+ # for pl in programs_local_stock:
+ # # logging.info("programs.check.stock.locally: " + pl.get("name") + " id: " + str(pl.get("id")))
+ # if pl.get("id") is not None and programs_cloud_map.get(pl.get("id")) is None:
+ # logging.info("programs.delete.stock.locally: " + pl.get("name"))
+ # # delete locally permanently
+ # ProgramEngine.get_instance().delete(pl.get("name"), logical=False)
+ self._sync_status["programs"] = "synced"
+ except cloud_api_robot_client.ApiException as e:
+ logging.warn("Exception when calling programs RobotSyncApi: %s\n" % e)
+ self._sync_status["programs"] = "failed"
\ No newline at end of file
diff --git a/coderbot/cnn/cnn_manager.py b/coderbot/cnn/cnn_manager.py
index 5ed012d2..6bd16db6 100644
--- a/coderbot/cnn/cnn_manager.py
+++ b/coderbot/cnn/cnn_manager.py
@@ -44,13 +44,13 @@ class CNNManager(object):
instance = None
@classmethod
- def get_instance(cls):
+ def get_instance(cls, settings=None):
if cls.instance is None:
- cls.instance = CNNManager()
+ cls.instance = CNNManager(settings)
return cls.instance
- def __init__(self):
+ def __init__(self, settings):
try:
f = open(MODEL_METADATA, "r")
self._models = json.load(f)
diff --git a/coderbot/coderbot.py b/coderbot/coderbot.py
index 37877506..44d4d31b 100644
--- a/coderbot/coderbot.py
+++ b/coderbot/coderbot.py
@@ -101,7 +101,7 @@ class CoderBot(object):
# pylint: disable=too-many-instance-attributes
- def __init__(self, motor_trim_factor=1.0, motor_min_power=0, motor_max_power=100, hw_version="5", pid_params=(0.8, 0.1, 0.01, 200, 0.01)):
+ def __init__(self, settings, motor_trim_factor=1.0, motor_min_power=0, motor_max_power=100, hw_version="5", pid_params=(0.8, 0.1, 0.01, 200, 0.01)):
try:
self._mpu = mpu.AccelGyroMag()
logging.info("MPU available")
@@ -157,9 +157,9 @@ def exit(self):
s.cancel()
@classmethod
- def get_instance(cls, motor_trim_factor=1.0, motor_max_power=100, motor_min_power=0, hw_version="5", pid_params=(0.8, 0.1, 0.01, 200, 0.01)):
+ def get_instance(cls, settings=None, motor_trim_factor=1.0, motor_max_power=100, motor_min_power=0, hw_version="5", pid_params=(0.8, 0.1, 0.01, 200, 0.01)):
if not cls.the_bot:
- cls.the_bot = CoderBot(motor_trim_factor=motor_trim_factor, motor_max_power= motor_max_power, motor_min_power=motor_min_power, hw_version=hw_version, pid_params=pid_params)
+ cls.the_bot = CoderBot(settings=settings, motor_trim_factor=motor_trim_factor, motor_max_power= motor_max_power, motor_min_power=motor_min_power, hw_version=hw_version, pid_params=pid_params)
return cls.the_bot
def get_motor_power(self, speed):
diff --git a/coderbot/config.py b/coderbot/config.py
index 071dd7da..a2098973 100644
--- a/coderbot/config.py
+++ b/coderbot/config.py
@@ -52,3 +52,6 @@ def restore(cls):
with open(CONFIG_DEFAULT_FILE) as f:
cls.write(json.loads(f.read()))
+ @classmethod
+ def modified(cls):
+ return os.stat(CONFIG_FILE).st_mtime
\ No newline at end of file
diff --git a/coderbot/cv/image.py b/coderbot/cv/image.py
index 24dd9efb..e9c6f12e 100644
--- a/coderbot/cv/image.py
+++ b/coderbot/cv/image.py
@@ -39,8 +39,7 @@ class Image():
_aruco_dict = cv2.aruco.Dictionary_get(cv2.aruco.DICT_ARUCO_ORIGINAL)
_aruco_parameters = cv2.aruco.DetectorParameters_create()
- #_face_cascade = cv2.CascadeClassifier('/usr/local/share/OpenCV/haarcascades/haarcascade_frontalface_default.xml')
- _face_cascade = cv2.CascadeClassifier('/usr/share/opencv/lbpcascades/lbpcascade_frontalface.xml')
+ _face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
def __init__(self, array):
self._data = array
@@ -85,7 +84,8 @@ def get_transform(cls, image_size_x):
return tx
def find_faces(self):
- faces = self._face_cascade.detectMultiScale(self._data)
+ gray = cv2.cvtColor(self._data, cv2.COLOR_BGR2GRAY)
+ faces = self._face_cascade.detectMultiScale(gray)
return faces
def filter_color(self, color):
diff --git a/coderbot/main.py b/coderbot/main.py
index edcb53d6..fb001fb4 100644
--- a/coderbot/main.py
+++ b/coderbot/main.py
@@ -7,8 +7,9 @@
import logging.handlers
import picamera
import connexion
-
-from flask_cors import CORS
+from connexion.options import SwaggerUIOptions
+from connexion.middleware import MiddlewarePosition
+from starlette.middleware.cors import CORSMiddleware
from camera import Camera
from motion import Motion
@@ -18,36 +19,34 @@
from cnn.cnn_manager import CNNManager
from event import EventManager
from coderbot import CoderBot
+from cloud.sync import CloudManager
# Logging configuration
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOGLEVEL", "INFO"))
-# sh = logging.StreamHandler()
-# formatter = logging.Formatter('%(message)s')
-# sh.setFormatter(formatter)
-# logger.addHandler(sh)
## (Connexion) Flask app configuration
-
# Serve a custom version of the swagger ui (Jinja2 templates) based on the default one
# from the folder 'swagger-ui'. Clone the 'swagger-ui' repository inside the backend folder
-options = {"swagger_ui": False}
-connexionApp = connexion.App(__name__, options=options)
-
-# Connexion wraps FlaskApp, so app becomes connexionApp.app
-app = connexionApp.app
-# Access-Control-Allow-Origin
-CORS(app)
-app.debug = False
+swagger_ui_options = SwaggerUIOptions(swagger_ui=True)
+app = connexion.App(__name__, swagger_ui_options=swagger_ui_options)
+app.add_middleware(
+ CORSMiddleware,
+ position=MiddlewarePosition.BEFORE_EXCEPTION,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
app.prog_engine = ProgramEngine.get_instance()
## New API and web application
# API v1 is defined in v1.yml and its methods are in api.py
-connexionApp.add_api('v1.yml')
+app.add_api('v1.yml')
def button_pushed():
- if app.bot_config.get('button_func') == "startstop":
+ if app.settings.get('button_func') == "startstop":
prog = app.prog_engine.get_current_prog()
if prog and prog.is_running():
prog.end()
@@ -66,38 +65,53 @@ def run_server():
cam = None
try:
try:
- app.bot_config = Config.read()
-
- bot = CoderBot.get_instance()
-
+ settings = Config.read().get("settings")
+ # if settings.get("id") is None:
+ # settings["id"] = str(uuid.uuid4()) # init uuid for local settings
+ # Config.write()
+
+ app.settings = settings
+ network_settings = Config.read().get("network")
+
+ bot = CoderBot.get_instance(settings=settings, motor_trim_factor=float(settings.get('move_motor_trim', 1.0)),
+ motor_max_power=int(settings.get('motor_max_power', 100)),
+ motor_min_power=int(settings.get('motor_min_power', 0)),
+ hw_version=settings.get('hardware_version'),
+ pid_params=(float(settings.get('pid_kp', 1.0)),
+ float(settings.get('pid_kd', 0.1)),
+ float(settings.get('pid_ki', 0.01)),
+ float(settings.get('pid_max_speed', 200)),
+ float(settings.get('pid_sample_time', 0.01))))
try:
- audio_device = Audio.get_instance()
- audio_device.set_volume(int(app.bot_config.get('audio_volume_level')), 100)
- audio_device.say(app.bot_config.get("sound_start"))
+ audio_device = Audio.get_instance(settings)
+ audio_device.set_volume(int(settings.get('audio_volume_level')), 100)
+ audio_device.say(settings.get("sound_start"))
except Exception:
logging.warning("Audio not present")
-
try:
- cam = Camera.get_instance()
- Motion.get_instance()
+ cam = Camera.get_instance(settings)
+ Motion.get_instance(settings)
except picamera.exc.PiCameraError:
logging.warning("Camera not present")
- CNNManager.get_instance()
+ CNNManager.get_instance(settings)
EventManager.get_instance("coderbot")
- if app.bot_config.get('load_at_start') and app.bot_config.get('load_at_start'):
- prog = app.prog_engine.load(app.bot_config.get('load_at_start'))
+ if settings.get('load_at_start') and settings.get('load_at_start'):
+ prog = app.prog_engine.load(settings.get('load_at_start'))
prog.execute()
+
+ CloudManager.get_instance()
+
except ValueError as e:
- app.bot_config = {}
+ settings = {}
logging.error(e)
bot.set_callback(bot.GPIOS.PIN_PUSHBUTTON, button_pushed, 100)
remove_doreset_file()
- app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False, threaded=True)
+ app.run(host="0.0.0.0", port=5000)
finally:
if cam:
cam.exit()
diff --git a/coderbot/motion.py b/coderbot/motion.py
index 95d66563..8fa5c14d 100644
--- a/coderbot/motion.py
+++ b/coderbot/motion.py
@@ -43,9 +43,9 @@
class Motion:
# pylint: disable=too-many-instance-attributes
- def __init__(self):
- self.bot = CoderBot.get_instance()
- self.cam = Camera.get_instance()
+ def __init__(self, settings):
+ self.bot = CoderBot.get_instance(settings)
+ self.cam = Camera.get_instance(settings)
self.track_len = 2
self.detect_interval = 5
self.tracks = []
@@ -59,20 +59,19 @@ def __init__(self):
self.target_dist = 0.0
self.delta_angle = 0.0
self.target_angle = 0.0
- cfg = Config.get()
- self.power_angles = [[15, (int(cfg.get("move_power_angle_1")), -1)],
- [4, (int(cfg.get("move_power_angle_2")), 0.05)],
- [1, (int(cfg.get("move_power_angle_3")), 0.02)],
+ self.power_angles = [[15, (int(settings.get("move_power_angle_1")), -1)],
+ [4, (int(settings.get("move_power_angle_2")), 0.05)],
+ [1, (int(settings.get("move_power_angle_3")), 0.02)],
[0, (0, 0)]]
- self.image_width = 640 / int(cfg.get("cv_image_factor"))
- self.image_heigth = 480 / int(cfg.get("cv_image_factor"))
+ self.image_width = 640 / int(settings.get("cv_image_factor"))
+ self.image_heigth = 480 / int(settings.get("cv_image_factor"))
self.transform = image.Image.get_transform(self.image_width)
_motion = None
@classmethod
- def get_instance(cls):
+ def get_instance(cls, settings=None):
if not cls._motion:
- cls._motion = Motion()
+ cls._motion = Motion(settings)
return cls._motion
def move(self, dist):
diff --git a/coderbot/music.py b/coderbot/music.py
index 0544b468..be1ac222 100644
--- a/coderbot/music.py
+++ b/coderbot/music.py
@@ -29,6 +29,7 @@
import os
import sox
import time
+import logging
class Music:
_instance = None
@@ -42,17 +43,17 @@ class Music:
@classmethod
- def get_instance(cls,managerPackage):
+ def get_instance(cls, managerPackage, settings=None):
if cls._instance is None:
- cls._instance = Music(managerPackage)
+ cls._instance = Music(managerPackage, settings)
return cls._instance
- def __init__(self,managerPackage):
+ def __init__(self, managerPackage, settings):
#os.putenv('AUDIODRIVER', 'alsa')
#os.putenv('AUDIODEV', 'hw:1,0')
self.managerPackage = managerPackage
- print("We have create a class: MUSICAL")
+ logging.info("We have created a class: MUSICAL")
def test(self):
tfm = sox.Transformer()
@@ -71,7 +72,7 @@ def play_pause(self, duration):
# @para alteration: if it is a diesis or a bemolle
# @param time: duration of the note in seconds
def play_note(self, note, instrument='piano', alteration='none', duration=1.0):
- print(note)
+ logging.info("play_note: %s", note)
tfm = sox.Transformer()
duration = float(duration)
@@ -85,7 +86,7 @@ def play_note(self, note, instrument='piano', alteration='none', duration=1.0):
if note in self.noteDict :
shift = self.noteDict[note]+ alt
else:
- print('note not exist')
+ logging.error('note does not exist')
return
tfm.pitch(shift, quick=False)
@@ -93,7 +94,7 @@ def play_note(self, note, instrument='piano', alteration='none', duration=1.0):
if self.managerPackage.isPackageAvailable(instrument):
tfm.preview('./sounds/notes/' + instrument + '/audio.wav')
else:
- print("no instrument:"+str(instrument)+" present in this coderbot!")
+ logging.error("no instrument:"+str(instrument)+" present in this coderbot!")
def play_animal(self, instrument, note='G2', alteration='none', duration=1.0):
tfm = sox.Transformer()
@@ -138,13 +139,13 @@ def play_animal(self, instrument, note='G2', alteration='none', duration=1.0):
if note in self.noteDict :
shift = self.noteDict[note]+ alt
else:
- print('note not exist')
+ logging.error('note does not exist')
return
if self.managerPackage.isPackageAvailable(instrument):
tfm.preview('./sounds/notes/' + instrument + '/audio.wav')
else:
- print("no animal verse:"+str(instrument)+" present in this coderbot!")
+ logging.error("no animal verse:"+str(instrument)+" present in this coderbot!")
return
tfm.pitch(shift, quick=False)
tfm.trim(0.0, end_time=0.5*duration)
diff --git a/coderbot/musicPackages.py b/coderbot/musicPackages.py
index e671b7e7..a96c9ba3 100644
--- a/coderbot/musicPackages.py
+++ b/coderbot/musicPackages.py
@@ -68,7 +68,7 @@ def addInterface(self, musicPackageInterface):
class MusicPackageInterface:
- def __init__(self,interfaceName,available,icon):
+ def __init__(self, interfaceName, available,icon):
self.interfaceName = interfaceName
self.available = available
self.icon = icon
@@ -86,12 +86,12 @@ class MusicPackageManager:
_instance = None
@classmethod
- def get_instance(cls):
+ def get_instance(cls, settings=None):
if cls._instance is None:
- cls._instance = MusicPackageManager()
+ cls._instance = MusicPackageManager(settings)
return cls._instance
- def __init__(self):
+ def __init__(self, settings):
self.packages = dict()
with open('./sounds/notes/music_package.json') as json_file:
data = json.load(json_file)
diff --git a/coderbot/program.py b/coderbot/program.py
index d76395fd..0cf275ff 100644
--- a/coderbot/program.py
+++ b/coderbot/program.py
@@ -20,10 +20,12 @@
import os
import threading
import json
+import uuid
import shutil
import logging
-
+from datetime import datetime
import math
+
from tinydb import TinyDB, Query
from threading import Lock
@@ -42,6 +44,10 @@
PROGRAM_SUFFIX = ".json"
PROGRAMS_DB = "data/programs.json"
PROGRAMS_PATH_DEFAULTS = "defaults/programs/"
+PROGRAM_STATUS_ACTIVE = "active"
+PROGRAM_STATUS_DELETED = "deleted"
+PROGRAM_KIND_STOCK = "stock"
+PROGRAM_KIND_USER = "user"
musicPackageManager = musicPackages.MusicPackageManager.get_instance()
@@ -75,67 +81,94 @@ class ProgramEngine:
_instance = None
- def __init__(self):
+ def __init__(self, settings):
self._program = None
self._log = ""
self._programs = TinyDB(PROGRAMS_DB)
+
# initialise DB from default programs
query = Query()
self.lock = Lock()
for dirname, dirnames, filenames, in os.walk(PROGRAMS_PATH_DEFAULTS):
- dirnames
for filename in filenames:
if PROGRAM_PREFIX in filename:
program_name = filename[len(PROGRAM_PREFIX):-len(PROGRAM_SUFFIX)]
- logging.info("adding program %s in path %s as default %r", program_name, dirname, ("default" in dirname))
- with open(os.path.join(dirname, filename), "r") as f:
- program_dict = json.load(f)
- program_dict["default"] = "default" in dirname
- program = Program.from_dict(program_dict)
- self.save(program)
+ if self.load_by_name(program_name) is None:
+ logging.info("adding program %s in path %s as default %r", program_name, dirname, ("default" in dirname))
+ with open(os.path.join(dirname, filename), "r") as f:
+ program_dict = json.load(f)
+ program_dict["kind"] = PROGRAM_KIND_STOCK
+ program_dict["status"] = PROGRAM_STATUS_ACTIVE
+ program = Program.from_dict(program_dict)
+ self.save(program)
@classmethod
- def get_instance(cls):
+ def get_instance(cls, settings=None):
if not cls._instance:
- cls._instance = ProgramEngine()
+ cls._instance = ProgramEngine(settings)
return cls._instance
- def prog_list(self):
- return self._programs.all()
+ def prog_list(self, active_only = True):
+ programs = None
+ query = Query()
+ if active_only:
+ programs = self._programs.search(query.status == PROGRAM_STATUS_ACTIVE)
+ else:
+ programs = self._programs.all()
+ return programs
def save(self, program):
with self.lock:
query = Query()
+ program._modified = datetime.now()
self._program = program
program_db_entry = self._program.as_dict()
- if self._programs.search(query.name == program.name) != []:
- self._programs.update(program_db_entry, query.name == program.name)
+ if self._programs.search(query.id == program._id) != []:
+ self._programs.update(program_db_entry, query.id == program._id)
else:
self._programs.insert(program_db_entry)
+ return program_db_entry
- def load(self, name):
+ def load(self, id, active_only=True):
with self.lock:
query = Query()
- program_db_entries = self._programs.search(query.name == name)
+ program_db_entries = None
+ if active_only:
+ program_db_entries = self._programs.search((query.id == id) & (query.status == PROGRAM_STATUS_ACTIVE))
+ else:
+ program_db_entries = self._programs.search(query.id == id)
if len(program_db_entries) > 0:
prog_db_entry = program_db_entries[0]
- logging.debug(prog_db_entry)
+ #logging.debug(prog_db_entry)
self._program = Program.from_dict(prog_db_entry)
return self._program
return None
- def delete(self, name):
- with self.lock:
+ def load_by_name(self, name):
+ with self.lock:
+ program = None
query = Query()
- program_db_entries = self._programs.search(query.name == name)
- self._programs.remove(query.name == name)
+ programs = self._programs.search((query.name == name) & (query.status == PROGRAM_STATUS_ACTIVE))
+ if len(programs) > 0:
+ program = Program.from_dict(programs[0])
+ return program
- def create(self, name, code):
- self._program = Program(name, code)
- return self._program
+ def delete(self, id, logical = True):
+ with self.lock:
+ query = Query()
+ program_db_entries = self._programs.search(query.id == id)
+ if len(program_db_entries) > 0:
+ program_db_entry = program_db_entries[0]
+ if logical:
+ program_db_entry["status"] = PROGRAM_STATUS_DELETED
+ program_db_entry["modified"] = datetime.now().isoformat()
+ self._programs.update(program_db_entry, query.id == id)
+ else:
+ self._programs.remove(query.id == id)
+ return None
- def is_running(self, name):
- return self._program.is_running() and self._program.name == name
+ def is_running(self, id):
+ return self._program.is_running() and self._program.id == id
def check_end(self):
return self._program.check_end()
@@ -159,12 +192,16 @@ class Program:
def dom_code(self):
return self._dom_code
- def __init__(self, name, code=None, dom_code=None, default=False):
+ def __init__(self, name, id=None, description=None, code=None, dom_code=None, kind=PROGRAM_KIND_USER, modified=None, status=None):
self._thread = None
- self.name = name
+ self._id = id if id is not None else str(uuid.uuid4())
+ self._name = name
+ self._description = description
self._dom_code = dom_code
self._code = code
- self._default = default
+ self._kind = kind
+ self._modified = modified
+ self._status = status
def execute(self, options={}):
if self._running:
@@ -195,8 +232,8 @@ def check_end(self):
def is_running(self):
return self._running
- def is_default(self):
- return self._default
+ def is_stock(self):
+ return self._kind == PROGRAM_KIND_STOCK
def run(self, *args):
options = args[0]
@@ -238,12 +275,25 @@ def run(self, *args):
self._running = False
+ @property
+ def name(self):
+ return self._name
+
def as_dict(self):
- return {'name': self.name,
+ return {'name': self._name,
'dom_code': self._dom_code,
'code': self._code,
- 'default': self._default}
+ 'kind': self._kind,
+ 'id': self._id,
+ 'modified': self._modified.isoformat(),
+ 'status': self._status}
@classmethod
def from_dict(cls, amap):
- return Program(name=amap['name'], dom_code=amap['dom_code'], code=amap['code'], default=amap.get('default', False))
+ return Program(name=amap['name'],
+ dom_code=amap['dom_code'],
+ code=amap['code'],
+ kind=amap.get('kind', PROGRAM_KIND_USER),
+ id=amap.get('id', None),
+ modified=datetime.fromisoformat(amap.get('modified', datetime.now().isoformat())),
+ status=amap.get('status', None),)
diff --git a/coderbot/v1.yml b/coderbot/v1.yml
index a3c8872f..86677cdd 100644
--- a/coderbot/v1.yml
+++ b/coderbot/v1.yml
@@ -30,6 +30,10 @@ paths:
responses:
200:
description: "ok"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Settings'
tags:
- CoderBot configuration
/settings/restore:
@@ -151,6 +155,27 @@ paths:
description: "ok"
/programs:
+ post:
+ operationId: "api.saveNewProgram"
+ summary: "Save a new program"
+ tags:
+ - Program management
+ requestBody:
+ description: Program object
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Program'
+ responses:
+ 200:
+ description: "ok"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Program'
+ 400:
+ description: "Failed to save the program"
get:
operationId: "api.listPrograms"
summary: "Get the list of all the saved programs"
@@ -160,12 +185,12 @@ paths:
200:
description: "ok"
- /programs/{name}:
+ /programs/{id}:
get:
operationId: "api.loadProgram"
summary: "Get the program with the specified name"
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -179,7 +204,7 @@ paths:
operationId: "api.deleteProgram"
summary: "Delete a program"
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -195,7 +220,7 @@ paths:
tags:
- Program management
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -213,37 +238,30 @@ paths:
400:
description: "Failed to save the program"
- /programs/{name}/run:
+ /programs/{id}/run:
post:
operationId: "api.runProgram"
summary: "Execute the given program"
tags:
- Program management
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
type: string
- requestBody:
- description: Program object
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Program'
responses:
200:
description: "ok"
- /programs/{name}/status:
+ /programs/{id}/status:
get:
operationId: "api.statusProgram"
summary: "Get the status of the given program"
tags:
- Program management
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -252,14 +270,14 @@ paths:
200:
description: "ok"
- /programs/{name}/stop:
+ /programs/{id}/stop:
patch:
operationId: "api.stopProgram"
summary: "Stop the given program"
tags:
- Program management
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -277,6 +295,13 @@ paths:
responses:
200:
description: "ok"
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Activity'
+
post:
operationId: "api.saveAsNewActivity"
summary: "Save a new activity"
@@ -292,14 +317,18 @@ paths:
responses:
200:
description: "ok"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Activity'
400:
description: "Failed to save the activity"
- /activities/{name}:
+ /activities/{id}:
get:
operationId: "api.loadActivity"
summary: "Get the activity with the specified name"
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -314,11 +343,16 @@ paths:
responses:
200:
description: "ok"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Activity'
+
put:
operationId: "api.saveActivity"
- summary: "Save the activity with the specified name"
+ summary: "Save the activity with the specified id"
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -335,13 +369,18 @@ paths:
responses:
200:
description: "ok"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Activity'
+
delete:
operationId: "api.deleteActivity"
summary: "Delete an activity"
tags:
- Activity management
parameters:
- - name: name
+ - name: id
in: path
required: true
schema:
@@ -606,6 +645,70 @@ paths:
200:
description: "CNN Model deleted"
+ /cloud/registration:
+ post:
+ operationId: "api.cloudRegistrationRequest"
+ summary: "request a Registration activity"
+ tags:
+ - Cloud Sync
+ requestBody:
+ description: Registration Request parameters
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CloudRegistrationRequest'
+ responses:
+ 200:
+ description: "Cloud Sync request"
+ get:
+ operationId: "api.cloudRegistrationStatus"
+ summary: "get status of a Registration activity"
+ tags:
+ - Cloud Sync
+ responses:
+ 200:
+ description: "Cloud Sync request"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CloudRegistrationStatus'
+
+ delete:
+ operationId: "api.cloudRegistrationDelete"
+ summary: "get status of a Registration activity"
+ tags:
+ - Cloud Sync
+ responses:
+ 200:
+ description: "Cloud Sync request"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CloudRegistrationStatus'
+
+ /cloud/sync:
+ post:
+ operationId: "api.cloudSyncRequest"
+ summary: "request a Sync activity"
+ tags:
+ - Cloud Sync
+ responses:
+ 200:
+ description: "Cloud Sync request"
+ get:
+ operationId: "api.cloudSyncStatus"
+ summary: "request a Sync activity"
+ tags:
+ - Cloud Sync
+ responses:
+ 200:
+ description: "Cloud Sync request"
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CloudSyncStatus'
+
components:
schemas:
MoveParamsElapse:
@@ -684,6 +787,11 @@ components:
Program:
type: object
properties:
+ id:
+ type: string
+ #pattern: '^[[:xdigit:]]{8}(?:\-[[:xdigit:]]{4}){3}\-[[:xdigit:]]{12}$'
+ minLength: 36
+ maxLength: 36
name:
type: string
pattern: '^[a-zA-ZA-zÀ-ú0-9-_ ]+$'
@@ -705,6 +813,11 @@ components:
Activity:
type: object
properties:
+ id:
+ type: string
+ #pattern: '^[[:xdigit:]]{8}(?:\-[[:xdigit:]]{4}){3}\-[[:xdigit:]]{12}$/
+ minLength: 36
+ maxLength: 36
name:
type: string
minLength: 1
@@ -715,11 +828,55 @@ components:
maxLength: 256
default:
type: boolean
- stock:
- type: boolean
+ kind:
+ type: string
required:
- name
- description
- default
- - stock
-
+ - kind
+
+ CloudRegistrationRequest:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 4
+ maxLength: 64
+ description:
+ type: string
+ minLength: 4
+ maxLength: 256
+ otp:
+ type: string
+ minLength: 8
+ maxLength: 8
+
+ CloudRegistrationStatus:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description:
+ type: string
+ minLength: 0
+ maxLength: 256
+ org_id:
+ type: string
+ org_name:
+ type: string
+ org_description:
+ type: string
+
+ CloudSyncStatus:
+ type: object
+ properties:
+ settings:
+ type: string
+ activities:
+ type: string
+ programs:
+ type: string
+
diff --git a/defaults/config.json b/defaults/config.json
index c206cc0a..6c43815a 100644
--- a/defaults/config.json
+++ b/defaults/config.json
@@ -1,54 +1,62 @@
{
- "move_power_angle_3":"60",
- "cnn_default_model":"generic_fast_low",
- "prog_maxblocks":"-1",
- "camera_jpeg_quality":"5",
- "show_page_control":"true",
- "camera_framerate":"30",
- "prog_scrollbars":"true",
- "move_fw_speed":"100",
- "motor_min_power":"0",
- "motor_max_power":"100",
- "prog_level":"adv",
- "move_motor_trim":"1",
- "cv_image_factor":"2",
- "move_power_angle_1":"45",
- "camera_path_object_size_min":"4000",
- "button_func":"none",
- "camera_color_object_size_min":"4000",
- "camera_jpeg_bitrate":"1000000",
- "move_fw_elapse":"1",
- "show_control_move_commands":"true",
- "camera_color_object_size_max":"160000",
- "show_page_prefs":"true",
- "camera_exposure_mode":"auto",
- "ctrl_tr_elapse":"-1",
- "show_page_program":"true",
- "move_tr_elapse":"0.5",
- "camera_path_object_size_max":"160000",
- "sound_shutter":"$shutter",
- "ctrl_fw_elapse":"-1",
- "sound_stop":"$shutdown",
- "ctrl_tr_speed":"80",
- "ctrl_fw_speed":"100",
- "move_tr_speed":"85",
- "move_power_angle_2":"60",
- "ctrl_hud_image":"",
- "load_at_start":"",
- "sound_start":"$startup",
- "hardware_version":"5",
- "audio_volume_level":"100",
- "wifi_mode":"ap",
- "wifi_ssid":"coderbot",
- "wifi_psk":"coderbot",
- "packages_installed":"",
- "admin_password":"",
- "pid_kp":"8.0",
- "pid_kd":"0.0",
- "pid_ki":"0.0",
- "pid_max_speed":"200",
- "pid_sample_time":"0.05",
- "movement_use_mpu": "false",
- "movement_use_motion": "false",
- "movement_use_encoder": "true"
-}
+ "settings":{
+ "ctrl_hud_image":"",
+ "cv_image_factor":"2",
+ "camera_color_object_size_max":"160000",
+ "camera_color_object_size_min":"4000",
+ "camera_exposure_mode":"auto",
+ "camera_framerate":"30",
+ "camera_jpeg_bitrate":"1000000",
+ "camera_jpeg_quality":"5",
+ "camera_path_object_size_max":"160000",
+ "camera_path_object_size_min":"4000",
+ "cnn_default_model":"generic_fast_low",
+ "move_power_angle_1":"45",
+ "move_power_angle_2":"60",
+ "move_power_angle_3":"60",
+ "button_func":"none",
+ "move_motor_trim":"1",
+ "motor_min_power":"0",
+ "motor_max_power":"100",
+ "sound_start":"$startup",
+ "sound_stop":"$shutdown",
+ "sound_shutter":"$shutter",
+ "load_at_start":"",
+ "prog_level":"adv",
+ "move_fw_elapse":"1",
+ "move_fw_speed":"100",
+ "move_tr_elapse":"0.5",
+ "move_tr_speed":"85",
+ "pid_kp":"8.0",
+ "pid_kd":"0.0",
+ "pid_ki":"0.0",
+ "pid_max_speed":"200",
+ "pid_sample_time":"0.05",
+ "ctrl_fw_elapse":"-1",
+ "ctrl_fw_speed":"100",
+ "ctrl_tr_elapse":"-1",
+ "ctrl_tr_speed":"80",
+ "audio_volume_level":"100",
+ "admin_password":"",
+ "hardware_version":"5",
+ "prog_scrollbars":"true",
+ "movement_use_mpu":"false",
+ "movement_use_motion":"false",
+ "movement_use_encoder":"true",
+ "locale":"browser"
+ },
+ "network":{
+ "wifi_mode":"ap",
+ "wifi_ssid":"coderbot",
+ "wifi_psk":"coderbot"
+ },
+ "cloud":{
+ "sync_modes":{
+ "activities":"n",
+ "programs":"n",
+ "settings":"n"
+ },
+ "sync_period":"10",
+ "reg_otp":"AB1234CD"
+ }
+}
\ No newline at end of file
diff --git a/defaults/programs/program_demo_ar_tags.json b/defaults/programs/program_demo_ar_tags.json
index 329f45d9..901ff1bc 100644
--- a/defaults/programs/program_demo_ar_tags.json
+++ b/defaults/programs/program_demo_ar_tags.json
@@ -1 +1 @@
-{"dom_code": "
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: