Repository: AccelerationConsortium/ac-training-lab
Pull request #178: A1 cam updates
Author: login: Jonathan-Woo
Created: 2025-02-28T23:54:26Z | Merged: 2025-03-19T00:09:57Z

Generally cleaned stuff up. Added logging, autofocus, quality options.


Diff:
diff --git a/.gitignore b/.gitignore
index e8626b0b..d1c153d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,5 @@ ac-training-lab.code-workspace
 
 src/ac_training_lab/pioreactor/lookhere.py
 src/ac_training_lab/bambu_a1_mini/info.py
+
+image.jpeg
diff --git a/setup.cfg b/setup.cfg
index 68ed7cc8..8029d98d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -67,6 +67,10 @@ testing =
     pytest
     pytest-cov
 
+a1-cam =
+    boto3
+    wget
+
 [options.entry_points]
 # Add here console scripts like:
 # console_scripts =
diff --git a/src/ac_training_lab/A1-cam/.gitignore b/src/ac_training_lab/A1-cam/.gitignore
index 8d4bfcfd..f3e5b958 100644
--- a/src/ac_training_lab/A1-cam/.gitignore
+++ b/src/ac_training_lab/A1-cam/.gitignore
@@ -1 +1,2 @@
 nohup.out
+my_secrets.py
diff --git a/src/ac_training_lab/A1-cam/README.md b/src/ac_training_lab/A1-cam/README.md
new file mode 100644
index 00000000..83dbbbc0
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/README.md
@@ -0,0 +1,45 @@
+# A1 Mini Overhead Toolhead Camera
+
+This is intended to be run on a Raspberry Pi Zero 2W with a Raspberry Pi Camera
+Module 3.
+
+Make a copy of `my_secrets_example.py` called `my_secrets.py` and
+fill in the necessary information. Keep in mind this will store the credentials in plain-text format.
+
+If not already installed (not pre-installed on RPi OS Lite), install [`picamera2`](https://github.com/raspberrypi/picamera2) via:
+
+```bash
+sudo apt install python3-picamera2 --no-install-recommends
+```
+
+`libcamera` should be automatically installed after installing `picamera2`. Otherwise, one would use `sudo apt install -y python3-libcamera` (`libcamera` also does not come preinstalled on RPi OS Lite versions).
+
+Optionally, you can use a virtual environment, but you will need to create it with the `--system-site-packages` flag via e.g., `python3 -m venv --system-site-packages venv` (installs to folder called "venv") so that it can use the `picamera2` and `libcamera` libraries. We are OK with *not* using a virtual environment because this device is intended to be run via a single top-level script, the RPi device (in our case RPi Zero 2W) requires minimal setup (i.e., can easily be reflashed), and the RPi device is intended for a single purpose with a single set of requirements (i.e., a "point-and-shoot" camera).
+
+```bash
+python3 -m venv --system-site-packages venv
+source venv/bin/activate
+```
+
+If you don't install a virtual environment and you are using a RPi OS Lite version, you will need to install pip first:
+```bash
+sudo apt install python3-pip -y
+```
+
+While in the same folder as this README file (e.g., `cd ac-training-lab/src/ac_training_lab/A1-cam`), run:
+
+```bash
+pip install -r requirements.txt
+```
+
+For local development with a dummy version of picamera2 (very minimal mock package), while in the same folder as this README file, additionally run:
+
+```bash
+pip install -e ./dummy_pkg/ # do not install this on the Raspberry Pi for the toolhead camera -- the imports will overlap with the "real" system packages `picamera2` and `libcamera`.
+```
+
+To start the device, run:
+
+```bash
+python3 device.py
+```
diff --git a/src/ac_training_lab/A1-cam/_scripts/boto3_file_upload.py b/src/ac_training_lab/A1-cam/_scripts/boto3_file_upload.py
new file mode 100644
index 00000000..e67c0ebb
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/_scripts/boto3_file_upload.py
@@ -0,0 +1,27 @@
+import os
+
+import boto3
+
+AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
+AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
+
+# Initialize the S3 client
+s3_client = boto3.client(
+    "s3",
+    aws_access_key_id=AWS_ACCESS_KEY_ID,
+    aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
+    region_name="us-east-2",
+)
+
+# Define the bucket name and the file details
+bucket_name = "rpi-zero2w-toolhead-camera"
+local_file_path = "src/ac_training_lab/A1-cam/_scripts/example_file.txt"
+s3_object_name = "example_file.txt"  # This can include subdirectories in S3
+
+# Will be very slow on the NOKIA (UoT preferred)
+print(f"Uploading file '{local_file_path}' to '{bucket_name}/{s3_object_name}'...")
+s3_client.upload_file(local_file_path, bucket_name, s3_object_name)
+
+print(
+    f"File '{local_file_path}' uploaded to '{bucket_name}/{s3_object_name}' successfully."  # noqa: E501
+)
diff --git a/src/ac_training_lab/A1-cam/_scripts/client.py b/src/ac_training_lab/A1-cam/_scripts/client.py
new file mode 100644
index 00000000..06947c7e
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/_scripts/client.py
@@ -0,0 +1,93 @@
+import json
+from queue import Queue
+
+import paho.mqtt.client as mqtt
+import requests
+from my_secrets import (
+    CAMERA_READ_TOPIC,
+    CAMERA_WRITE_TOPIC,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_PORT,
+    MQTT_USERNAME,
+)
+from PIL import Image
+
+data_queue: "Queue[dict]" = Queue()
+
+
+def get_paho_client(
+    sensor_data_topic, hostname, username, password=None, port=8883, tls=True
+):
+
+    client = mqtt.Client(
+        mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5
+    )  # create new instance
+
+    def on_message(client, userdata, msg):
+        # received_message will be the AWS URI
+        print(f"Received payload: {msg.payload}")
+        data = json.loads(msg.payload)
+        print(f"Parsed data: {data}")
+        data_queue.put(data)
+
+    # The callback for when the client receives a CONNACK response from the server.
+    def on_connect(client, userdata, flags, rc, properties=None):
+        if rc != 0:
+            print("Connected with result code " + str(rc))
+        # Subscribing in on_connect() means that if we lose the connection and
+        # reconnect then subscriptions will be renewed.
+        client.subscribe(sensor_data_topic, qos=2)
+
+    client.on_connect = on_connect
+    client.on_message = on_message
+
+    # enable TLS for secure connection
+    if tls:
+        client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS_CLIENT)
+    # set username and password
+    client.username_pw_set(username, password)
+    # connect to HiveMQ Cloud on port 8883 (default for MQTT)
+    client.connect(hostname, port)
+    client.subscribe(sensor_data_topic, qos=2)
+
+    return client
+
+
+def send_and_receive(client, command_topic, msg, queue_timeout=60):
+    payload = json.dumps(msg)
+    client.publish(command_topic, payload, qos=2)
+    print(f"Published payload: {payload}")
+
+    client.loop_start()
+
+    while True:
+        print("Waiting for data...")
+        data = data_queue.get(True, queue_timeout)
+        client.loop_stop()
+        return data
+
+
+client = get_paho_client(
+    CAMERA_WRITE_TOPIC, MQTT_HOST, MQTT_USERNAME, password=MQTT_PASSWORD, port=MQTT_PORT
+)
+
+msg = {"command": "capture_image"}
+
+try:
+    data = send_and_receive(client, CAMERA_READ_TOPIC, msg, queue_timeout=30)
+
+    image_uri = data["image_uri"]
+
+    response = requests.get(image_uri)
+    response.raise_for_status()
+    with open("image.jpeg", "wb") as f:
+        f.write(response.content)
+
+    print("Opening image...")
+    img = Image.open("image.jpeg")
+    img.show()
+finally:
+    client.loop_stop()
+    client.disconnect()
+    print("Disconnected from MQTT broker")
diff --git a/src/ac_training_lab/A1-cam/_scripts/example_file.txt b/src/ac_training_lab/A1-cam/_scripts/example_file.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/src/ac_training_lab/A1-cam/_scripts/module_4_mwe.py b/src/ac_training_lab/A1-cam/_scripts/module_4_mwe.py
new file mode 100644
index 00000000..1a88ecf6
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/_scripts/module_4_mwe.py
@@ -0,0 +1,68 @@
+import json
+from queue import Queue
+
+import paho.mqtt.client as mqtt
+
+COURSE_ID = "absurd-gazelle"
+
+command_topic = f"{COURSE_ID}/onboard_led"
+sensor_data_topic = f"{COURSE_ID}/onboard_temp"
+
+HIVEMQ_USERNAME = "sgbaird"
+HIVEMQ_PASSWORD = "D.Pq5gYtejYbU#L"
+HIVEMQ_HOST = "248cc294c37642359297f75b7b023374.s2.eu.hivemq.cloud"
+
+sensor_data_queue: "Queue[dict]" = Queue()
+
+
+def get_paho_client(
+    sensor_data_topic, hostname, username, password=None, port=8883, tls=True
+):
+
+    client = mqtt.Client(
+        mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5
+    )  # create new instance
+
+    def on_message(client, userdata, msg):
+        sensor_data_queue.put(json.loads(msg.payload))
+
+    # The callback for when the client receives a CONNACK response from the server.
+    def on_connect(client, userdata, flags, rc, properties=None):
+        if rc != 0:
+            print("Connected with result code " + str(rc))
+        # Subscribing in on_connect() means that if we lose the connection and
+        # reconnect then subscriptions will be renewed.
+        client.subscribe(sensor_data_topic, qos=1)
+
+    client.on_connect = on_connect
+    client.on_message = on_message
+
+    # enable TLS for secure connection
+    if tls:
+        client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS_CLIENT)
+    # set username and password
+    client.username_pw_set(username, password)
+    # connect to HiveMQ Cloud on port 8883 (default for MQTT)
+    client.connect(hostname, port)
+    client.subscribe(sensor_data_topic, qos=2)
+
+    return client
+
+
+def send_and_receive(client, command_topic, msg, queue_timeout=60):
+    client.publish(command_topic, msg, qos=2)
+
+    client.loop_start()
+
+    while True:
+        sensor_data = sensor_data_queue.get(True, queue_timeout)
+        client.loop_stop()
+        return sensor_data
+
+
+client = get_paho_client(
+    sensor_data_topic, HIVEMQ_HOST, HIVEMQ_USERNAME, password=HIVEMQ_PASSWORD
+)
+
+onboard_temp = send_and_receive(client, command_topic, "toggle", queue_timeout=30)
+print(onboard_temp)
diff --git a/src/ac_training_lab/A1-cam/_scripts/publish.py b/src/ac_training_lab/A1-cam/_scripts/publish.py
new file mode 100644
index 00000000..a4e24178
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/_scripts/publish.py
@@ -0,0 +1,66 @@
+import json
+from queue import Queue
+
+import paho.mqtt.client as mqtt
+from my_secrets import (
+    CAMERA_READ_TOPIC,
+    CAMERA_WRITE_TOPIC,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+sensor_data_queue: "Queue[dict]" = Queue()
+
+
+def get_paho_client(
+    sensor_data_topic, hostname, username, password=None, port=8883, tls=True
+):
+
+    client = mqtt.Client(
+        mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5
+    )  # create new instance
+
+    def on_message(client, userdata, msg):
+        sensor_data_queue.put(json.loads(msg.payload))
+
+    # The callback for when the client receives a CONNACK response from the server.
+    def on_connect(client, userdata, flags, rc, properties=None):
+        if rc != 0:
+            print("Connected with result code " + str(rc))
+        # Subscribing in on_connect() means that if we lose the connection and
+        # reconnect then subscriptions will be renewed.
+        client.subscribe(sensor_data_topic, qos=1)
+
+    client.on_connect = on_connect
+    client.on_message = on_message
+
+    # enable TLS for secure connection
+    if tls:
+        client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS_CLIENT)
+    # set username and password
+    client.username_pw_set(username, password)
+    # connect to HiveMQ Cloud on port 8883 (default for MQTT)
+    client.connect(hostname, port)
+    client.subscribe(sensor_data_topic, qos=2)
+
+    return client
+
+
+def send_and_receive(client, command_topic, msg, queue_timeout=60):
+    client.publish(command_topic, msg, qos=2)
+
+    client.loop_start()
+
+    while True:
+        sensor_data = sensor_data_queue.get(True, queue_timeout)
+        client.loop_stop()
+        return sensor_data
+
+
+client = get_paho_client(
+    CAMERA_WRITE_TOPIC, MQTT_HOST, MQTT_USERNAME, password=MQTT_PASSWORD
+)
+
+onboard_temp = send_and_receive(client, CAMERA_READ_TOPIC, "toggle", queue_timeout=30)
+print(onboard_temp)
diff --git a/src/ac_training_lab/A1-cam/_scripts/publish_subscribe.py b/src/ac_training_lab/A1-cam/_scripts/publish_subscribe.py
new file mode 100644
index 00000000..5624059a
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/_scripts/publish_subscribe.py
@@ -0,0 +1,73 @@
+import json
+from queue import Queue
+
+import paho.mqtt.client as mqtt
+from my_secrets import (
+    CAMERA_READ_TOPIC,
+    CAMERA_WRITE_TOPIC,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_PORT,
+    MQTT_USERNAME,
+)
+
+sensor_data_queue: "Queue[dict]" = Queue()
+
+
+def get_paho_client(
+    sensor_data_topic, hostname, username, password=None, port=8883, tls=True
+):
+    client = mqtt.Client(protocol=mqtt.MQTTv5)  # create new instance
+
+    def on_message(client, userdata, msg):
+        sensor_data_queue.put(json.loads(msg.payload))
+
+    def on_connect(client, userdata, flags, rc, properties=None):
+        if rc != 0:
+            print("Connected with result code " + str(rc))
+        client.subscribe(sensor_data_topic, qos=1)
+
+    client.on_connect = on_connect
+    client.on_message = on_message
+
+    if tls:
+        client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS_CLIENT)
+    client.username_pw_set(username, password)
+    client.connect(hostname, port)
+    client.subscribe(sensor_data_topic, qos=2)
+
+    return client
+
+
+def send_and_receive(client, command_topic, msg, queue_timeout=60):
+    client.publish(command_topic, msg, qos=2)
+    client.loop_start()
+
+    while True:
+        sensor_data = sensor_data_queue.get(True, queue_timeout)
+        client.loop_stop()
+        return sensor_data
+
+
+# Initialize the MQTT client
+client = get_paho_client(
+    CAMERA_WRITE_TOPIC, MQTT_HOST, MQTT_USERNAME, password=MQTT_PASSWORD, port=MQTT_PORT
+)
+
+# Publish a command message to trigger image capture and wait for the response
+command_payload = json.dumps({"command": "capture_image"})
+response = send_and_receive(
+    client, CAMERA_READ_TOPIC, command_payload, queue_timeout=30
+)
+
+# Process the received message
+if "image_url" in response:
+    print("Received image URL:", response["image_url"])
+elif "error" in response:
+    print("Error received from device:")
+    print("Error:", response.get("error"))
+    print("Traceback:", response.get("traceback"))
+else:
+    print("Unknown message received:", response)
+
+1 + 1
diff --git a/src/ac_training_lab/A1-cam/client.py b/src/ac_training_lab/A1-cam/client.py
deleted file mode 100644
index 182ffcfa..00000000
--- a/src/ac_training_lab/A1-cam/client.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import paho.mqtt.client as mqtt
-import wget
-from my_secrets import (
-    CAMERA_READ_ENDPOINT,
-    CLIENT_READ_ENDPOINT,
-    HIVEMQ_HOST,
-    HIVEMQ_PASSWORD,
-    HIVEMQ_USERNAME,
-    PORT,
-)
-
-
-# Publish a command
-def send_command(command):
-    client.publish(CAMERA_READ_ENDPOINT, command)
-    print(f"Sent command: {command}")
-
-
-def receive_message(client, userdata, msg):
-    # received_message will be the AWS URI
-    received_message = msg.payload.decode("utf-8")
-    print(f"Received message: {received_message}")
-    print("Downloading image:")
-    wget.download(received_message)
-
-
-client = mqtt.Client()
-
-client.on_message = receive_message
-
-client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS)
-client.username_pw_set(HIVEMQ_USERNAME, HIVEMQ_PASSWORD)
-client.connect(HIVEMQ_HOST, PORT)
-client.subscribe(CLIENT_READ_ENDPOINT, qos=2)
-
-# just request for one image, call more times to get more images
-send_command("capture")
-
-client.loop_forever()
diff --git a/src/ac_training_lab/A1-cam/device.py b/src/ac_training_lab/A1-cam/device.py
new file mode 100644
index 00000000..0a71e09d
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/device.py
@@ -0,0 +1,137 @@
+import json
+import sys
+import traceback
+from datetime import datetime, timezone
+from queue import Queue
+from time import sleep, time
+
+import boto3
+import paho.mqtt.client as mqtt
+from libcamera import Transform
+from my_secrets import (
+    AWS_ACCESS_KEY_ID,
+    AWS_REGION,
+    AWS_SECRET_ACCESS_KEY,
+    BUCKET_NAME,
+    CAMERA_READ_TOPIC,
+    CAMERA_WRITE_TOPIC,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_PORT,
+    MQTT_USERNAME,
+)
+from picamera2 import Picamera2
+
+command_queue: "Queue[dict]" = Queue()
+
+
+def on_message(client, userdata, msg):
+    try:
+        data = json.loads(msg.payload)
+        command = data["command"]
+
+        if command == "capture_image":
+            file_path = "image.jpeg"
+
+            picam2.autofocus_cycle()
+            picam2.capture_file(file_path)
+
+            object_name = (
+                datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+            )
+
+            s3.upload_file(file_path, BUCKET_NAME, object_name)
+
+            file_uri = (
+                f"https://{BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com/{object_name}"
+            )
+
+            data = {
+                "image_uri": file_uri,
+                # "bucket": BUCKET_NAME,
+                # "object_name": object_name,
+                # "region": AWS_REGION,
+            }
+            payload = json.dumps(data)
+            client.publish(CAMERA_WRITE_TOPIC, payload)
+            print(f"Published image URI: {file_uri}")
+    except Exception as e:
+        client.publish(CAMERA_WRITE_TOPIC, json.dumps({"error": str(e)}))
+        print(f"Error: {e}")
+
+
+# The callback for when the client receives a CONNACK response from the server.
+def on_connect(client, userdata, flags, rc, properties=None):
+    if rc != 0:
+        print("Connected with result code " + str(rc))
+    # Subscribing in on_connect() means that if we lose the connection and
+    # reconnect then subscriptions will be renewed.
+    client.subscribe(CAMERA_READ_TOPIC, qos=2)
+
+
+try:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
+
+    client.on_connect = on_connect
+    client.on_message = on_message
+
+    # enable TLS for secure connection
+    client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS_CLIENT)
+    # set username and password
+    client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
+    # connect to HiveMQ Cloud on port 8883 (default for MQTT)
+    client.connect(MQTT_HOST, MQTT_PORT)
+
+    print("Starting camera setup...")
+    picam2 = Picamera2()
+    picam2.set_controls({"AfMode": "auto"})
+    picam2.options["quality"] = 90
+    config = picam2.create_still_configuration(transform=Transform(vflip=1))
+    picam2.configure(config)
+    picam2.start()
+    print("Camera configured successfully.")
+
+    print("Setting up AWS S3...")
+    s3 = boto3.client(
+        "s3",
+        aws_access_key_id=AWS_ACCESS_KEY_ID,
+        aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
+        region_name=AWS_REGION,
+    )
+    print("AWS S3 configured successfully.")
+
+    print("MQTT client connected successfully.")
+    # Start the MQTT network loop in a separate thread
+    client.loop_start()
+    print("Waiting for commands...")
+
+    start_time = time()
+    # Keep the script running to handle incoming messages
+    while True:
+        elapsed = round(time() - start_time)
+        print(f"Running... Elapsed: {elapsed}s")
+        sleep(5)
+
+except Exception as e:
+    error_trace = traceback.format_exception(*sys.exc_info())
+
+    error_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
+    error_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
+    error_client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS_CLIENT)
+    error_client.connect(MQTT_HOST, MQTT_PORT)
+
+    error_payload = json.dumps({"error": str(e), "traceback": error_trace})
+    error_client.publish(CAMERA_WRITE_TOPIC, error_payload)
+    print("Error details published to MQTT.")
+
+    error_client.disconnect()
+
+    raise Exception(
+        "An error occurred. Please check the MQTT topic for details."
+    ) from e
+
+finally:
+    # Gracefully stop
+    client.loop_stop()
+    client.disconnect()
+    print("MQTT client disconnected.")
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/README.md b/src/ac_training_lab/A1-cam/dummy_pkg/README.md
new file mode 100644
index 00000000..8c0541a7
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/README.md
@@ -0,0 +1,11 @@
+Just a dummy package for making it easier to test the rest of the code on a
+non-RPi OS (e.g., Windows).
+
+
+To install, navigate to the directory containing the `dummy_pkg` directory and
+run the following commands (or just the appropriate paths as you like):
+```
+cd dummy_pkg
+pip install -e .
+cd ..
+```
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/pyproject.toml b/src/ac_training_lab/A1-cam/dummy_pkg/pyproject.toml
new file mode 100644
index 00000000..d1e6ae6e
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/pyproject.toml
@@ -0,0 +1,2 @@
+[build-system]
+requires = ["setuptools", "wheel"]
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/requirements.txt b/src/ac_training_lab/A1-cam/dummy_pkg/requirements.txt
new file mode 100644
index 00000000..3868fb16
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/requirements.txt
@@ -0,0 +1 @@
+pillow
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/setup.cfg b/src/ac_training_lab/A1-cam/dummy_pkg/setup.cfg
new file mode 100644
index 00000000..2f830d46
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/setup.cfg
@@ -0,0 +1,15 @@
+[metadata]
+name = dummy_pkg
+version = 0.1
+
+[options]
+packages = find:
+package_dir =
+    =src
+
+install_requires =
+    pillow
+    wget
+
+[options.packages.find]
+where=src
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/src/libcamera/__init__.py b/src/ac_training_lab/A1-cam/dummy_pkg/src/libcamera/__init__.py
new file mode 100644
index 00000000..1dfee213
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/src/libcamera/__init__.py
@@ -0,0 +1 @@
+from libcamera._libcamera import Transform  # noqa: F401
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/src/libcamera/_libcamera.py b/src/ac_training_lab/A1-cam/dummy_pkg/src/libcamera/_libcamera.py
new file mode 100644
index 00000000..6d34264c
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/src/libcamera/_libcamera.py
@@ -0,0 +1,12 @@
+import logging
+
+
+class Transform:
+    """
+    Dummy Transform class for smoke testing.
+    Does nothing special with flip transforms.
+    """
+
+    def __init__(self, vflip=0):
+        self.vflip = vflip
+        logging.info(f"Dummy Transform created with vflip={vflip}")
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/src/picamera2/__init__.py b/src/ac_training_lab/A1-cam/dummy_pkg/src/picamera2/__init__.py
new file mode 100644
index 00000000..258fd916
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/src/picamera2/__init__.py
@@ -0,0 +1 @@
+from picamera2._picamera2 import Picamera2  # noqa: F401
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/src/picamera2/_picamera2.py b/src/ac_training_lab/A1-cam/dummy_pkg/src/picamera2/_picamera2.py
new file mode 100644
index 00000000..52b50ea6
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/src/picamera2/_picamera2.py
@@ -0,0 +1,31 @@
+import logging
+
+from PIL import Image
+
+
+class Picamera2:
+    def __init__(self):
+        self.options = {}
+        print("Mock: Picamera2 initialized")
+
+    def set_controls(self, controls):
+        logging.info(f"Mock: Setting controls: {controls}")
+
+    def create_still_configuration(self, transform):
+        logging.info(f"Mock: Creating still configuration with transform: {transform}")
+        return {}
+
+    def configure(self, config):
+        logging.info(f"Mock: Configuring camera with config: {config}")
+
+    def start(self):
+        logging.info("Mock: Starting camera")
+
+    def autofocus_cycle(self):
+        logging.info("Mock: Performing autofocus cycle")
+
+    def capture_file(self, file_path):
+        logging.info(f"Mock: Capturing image to file: {file_path}")
+        with open(file_path, "wb") as f:
+            dummy_image = Image.new("RGB", (640, 480), color="red")
+            dummy_image.save(f, "JPEG")
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/tests/test_dummy_libcamera.py b/src/ac_training_lab/A1-cam/dummy_pkg/tests/test_dummy_libcamera.py
new file mode 100644
index 00000000..9556e251
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/tests/test_dummy_libcamera.py
@@ -0,0 +1,10 @@
+from libcamera import Transform
+
+
+def test_libcamera():
+    Transform(vflip=1)
+
+
+if __name__ == "__main__":
+    test_libcamera()
+    print("All tests passed.")
diff --git a/src/ac_training_lab/A1-cam/dummy_pkg/tests/test_dummy_picamera2.py b/src/ac_training_lab/A1-cam/dummy_pkg/tests/test_dummy_picamera2.py
new file mode 100644
index 00000000..ab20f495
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/dummy_pkg/tests/test_dummy_picamera2.py
@@ -0,0 +1,41 @@
+import os
+
+from picamera2 import Picamera2
+from PIL import Image
+
+# Get the directory of this test file
+TEST_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+def test_camera_setup():
+    picam2 = Picamera2()
+    picam2.start()
+    picam2.configure({})
+    picam2.set_controls({"AfMode": "auto"})
+    picam2.autofocus_cycle()
+    config = picam2.create_still_configuration(transform="vflip")
+    assert config == {}, "Expected empty config from dummy class"
+
+
+def test_capture_file():
+    picam2 = Picamera2()
+    # Write test image to the same folder as this test file
+    file_path = os.path.join(TEST_DIR, "test_image.jpeg")
+
+    # Verify an image file is created
+    picam2.capture_file(file_path)
+    assert os.path.exists(file_path), "Expected image file to be created."
+
+    # Verify the contents of the file form a valid image
+    with open(file_path, "rb") as f:
+        img = Image.open(f)
+        assert img.size == (640, 480), "Expected a 640x480 image."
+        assert img.mode == "RGB", "Expected the image to be in RGB mode."
+
+    os.remove(file_path)
+
+
+if __name__ == "__main__":
+    test_camera_setup()
+    test_capture_file()
+    print("All tests passed.")
diff --git a/src/ac_training_lab/A1-cam/my_secrets_example.py b/src/ac_training_lab/A1-cam/my_secrets_example.py
new file mode 100644
index 00000000..b22665ef
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/my_secrets_example.py
@@ -0,0 +1,9 @@
+MQTT_HOST = "your-mqtt-host"
+MQTT_PORT = 8883  # or 1883
+MQTT_USERNAME = "your-username"
+MQTT_PASSWORD = "your-password"
+DEVICE_SERIAL = "your-device-serial"
+CAMERA_READ_TOPIC = f"bambu_a1_mini/request/{DEVICE_SERIAL}"
+CAMERA_WRITE_TOPIC = f"bambu_a1_mini/response/{DEVICE_SERIAL}"
+BUCKET_NAME = "your-bucket-name"
+AWS_REGION = "your-region"
diff --git a/src/ac_training_lab/A1-cam/requirements.txt b/src/ac_training_lab/A1-cam/requirements.txt
new file mode 100644
index 00000000..28647589
--- /dev/null
+++ b/src/ac_training_lab/A1-cam/requirements.txt
@@ -0,0 +1,3 @@
+boto3
+paho-mqtt
+# picamera2 via `sudo apt install python3-picamera2` (see README)
diff --git a/src/ac_training_lab/A1-cam/server.py b/src/ac_training_lab/A1-cam/server.py
deleted file mode 100644
index 66e06551..00000000
--- a/src/ac_training_lab/A1-cam/server.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from datetime import datetime
-
-import boto3
-import paho.mqtt.client as mqtt
-from libcamera import Transform, controls
-from my_secrets import (
-    CAMERA_READ_ENDPOINT,
-    CLIENT_READ_ENDPOINT,
-    HIVEMQ_HOST,
-    HIVEMQ_PASSWORD,
-    HIVEMQ_USERNAME,
-    PORT,
-)
-from picamera2 import Picamera2
-
-
-def on_message(client, userdata, msg):
-    command = msg.payload.decode("utf-8")
-    print(f"Received command: {command}")
-    if command == "capture":
-        print("Capturing image...")
-        # only keeping one local copy at any given time called image.jpg
-        file_path = "image.jpg"
-
-        picam2.capture_file(file_path)
-
-        # using the current timestamp as the unique object ID
-        object_name = datetime.utcnow().strftime("%Y%m%d%H%M%S%f") + ".jpg"
-        # need to setup S3 bucket with ACL and public access
-        s3.upload_file(
-            file_path, BUCKET_NAME, object_name, ExtraArgs={"ACL": "public-read"}
-        )
-
-        file_uri = f"https://{BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com/{object_name}"
-        client.publish(CLIENT_READ_ENDPOINT, file_uri)
-
-
-# camera setup
-picam2 = Picamera2()
-picam2.set_controls({"AfMode": controls.AfModeEnum.Continuous})
-config = picam2.create_still_configuration(transform=Transform(vflip=1))
-picam2.configure(config)
-picam2.start()
-
-BUCKET_NAME = "jwoo-picam-bucket"
-AWS_REGION = "us-east-1"
-s3 = boto3.client("s3", region_name=AWS_REGION)
-
-# Initialize the MQTT Client
-client = mqtt.Client(client_id="", userdata=None, protocol=mqtt.MQTTv5)
-client.on_message = on_message
-
-client.tls_set(tls_version=mqtt.ssl.PROTOCOL_TLS)
-client.username_pw_set(HIVEMQ_USERNAME, HIVEMQ_PASSWORD)
-client.connect(HIVEMQ_HOST, PORT)
-client.subscribe(CAMERA_READ_ENDPOINT, qos=2)
-client.loop_forever()


Reviews:
user: login: sgbaird
body: The logic seems sound - overall was easy to follow the script. Few minor points and clarifications listed. Overall, it seems like a lot of logging - I'm guessing because it's operating in a headless state, usually unsupervised, and you want to keep track of it for when you go into the device to check?

I've certainly been one to implement this style at times, but usually I start simple, try to do some stress testing, and then add try-except logic as needed (rather than anticipating). For example, I iterated a lot on https://github.com/sparks-baird/self-driving-lab-demo/blob/main/src/public_mqtt_sdl_demo/main.py because it was pretty fragile and hard to debug without the logging. Some key commits: https://github.com/sparks-baird/self-driving-lab-demo/blob/6dc95458027962a303e93390992c34f7f1b9c262/src/public_mqtt_sdl_demo/main.py --> https://github.com/sparks-baird/self-driving-lab-demo/commit/8103b54f6872f89691289c51b9fe8185a5d89cbb

I don't know as well as you what all went wrong or is likely to go wrong (and what is making it easier to maintain) and I know you've done a lot of troubleshooting and debugging with the cameras, so I'll defer to you on this one. Just trying to get a handle on these topics within the training lab.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-01T00:59:35Z
user: login: sgbaird
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T00:09:37Z

Review comments:
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Did you run into errors here at any point?
created_at: 2025-03-01T00:06:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Did you run into errors here at any point?
created_at: 2025-03-01T00:06:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access so that the link works publically
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Thanks for comments like these!
created_at: 2025-03-01T00:07:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access so that the link works publically
+        # using the current timestamp as the unique object ID
+        object_name = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+        logging.info("Begin upload to S3")
+        try:
+            s3.upload_file(
+                file_path, BUCKET_NAME, object_name, ExtraArgs={"ACL": "public-read"}
+            )
+            logging.info("Successfully uploaded to S3")
+        except Exception as e:
+            logging.error(f"Error uploading to S3: {e}")
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Did you run into errors here at any point?
created_at: 2025-03-01T00:07:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access so that the link works publically
+        # using the current timestamp as the unique object ID
+        object_name = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+        logging.info("Begin upload to S3")
+        try:
+            s3.upload_file(
+                file_path, BUCKET_NAME, object_name, ExtraArgs={"ACL": "public-read"}
+            )
+            logging.info("Successfully uploaded to S3")
+        except Exception as e:
+            logging.error(f"Error uploading to S3: {e}")
+
+        file_uri = f"https://{BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com/{object_name}"
+
+        try:
+            client.publish(CAMERA_WRITE_ENDPOINT, json.dumps({"image_url": file_uri}))
+            logging.info(f"Published {file_uri} to {CAMERA_WRITE_ENDPOINT}")
+        except Exception as e:
+            logging.error(
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Did you run into errors here at any point?
created_at: 2025-03-01T00:07:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access so that the link works publically
+        # using the current timestamp as the unique object ID
+        object_name = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+        logging.info("Begin upload to S3")
+        try:
+            s3.upload_file(
+                file_path, BUCKET_NAME, object_name, ExtraArgs={"ACL": "public-read"}
+            )
+            logging.info("Successfully uploaded to S3")
+        except Exception as e:
+            logging.error(f"Error uploading to S3: {e}")
+
+        file_uri = f"https://{BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com/{object_name}"
+
+        try:
+            client.publish(CAMERA_WRITE_ENDPOINT, json.dumps({"image_url": file_uri}))
+            logging.info(f"Published {file_uri} to {CAMERA_WRITE_ENDPOINT}")
+        except Exception as e:
+            logging.error(
+                f"Error publishing {file_uri} to {CAMERA_WRITE_ENDPOINT}: {e}"
+            )
+
+
+if __name__ == "__main__":
+    # RPI Camera
+    logging.info("Configuring camera")
+    try:
+        picam2 = Picamera2()
+        picam2.set_controls({"AfMode": "auto"})
+        # JPEG quality level (0 is worst, 95 is best)
+        picam2.options["quality"] = 90
+        config = picam2.create_still_configuration(transform=Transform(vflip=1))
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Maybe comment that the vflip is because we use a camera mount that puts the camera upside down
created_at: 2025-03-01T00:08:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access so that the link works publically
+        # using the current timestamp as the unique object ID
+        object_name = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+        logging.info("Begin upload to S3")
+        try:
+            s3.upload_file(
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: How is s3 being used prior to being defined? If it were a global variable, I'd understand, but I only see it being defined in the `if __main__` towards the end. Maybe I'm missing something basic.
created_at: 2025-03-01T00:21:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Any thoughts about queuing in this context? I.e., what will happen if it gets a request while it's still processing another (will it be ignored completely, will it sit in a queue, etc.?). This is more of a fringe case atm, but I wanted to double check what the expected behavior is

I think I designed https://ac-microcourses.readthedocs.io/en/latest/courses/hello-world/1.4-hardware-software-communication.html and the corresponding assignment microcontroller script to handle requests in a FIFO style, but the use-case is somewhat different and that uses MicroPython `mqtt_as` instead of Python `paho-mqtt` (see also [orchestrator nb](https://ac-microcourses.readthedocs.io/en/latest/courses/hello-world/1.4.1-onboard-led-temp.html)).
created_at: 2025-03-01T00:25:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Could you comment on the reasoning behind the more involved logging setup? (file handler, stream handler, why default `basicConfig` doesn't suffice, etc.). I imagine something to do with the Pi using a headless OS and having a file in case of unexpected shut-down, while still being able to see the stdout when you're SSH'd into it and debugging/prototyping.
created_at: 2025-03-01T00:27:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: In this case because it's MQTT, let's call this a topic rather than an endpoint
created_at: 2025-03-01T00:28:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Is the public access setup such that anyone can read files if given the URI, but access is restricted for the upload of files? (i.e., requires authentication)
created_at: 2025-03-01T00:39:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+import paho.mqtt.client as mqtt
+import boto3
+import json
+from datetime import datetime
+import time
+
+from picamera2 import Picamera2
+from libcamera import controls, Transform
+
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_PORT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_USERNAME,
+)
+
+import logging
+import sys
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access so that the link works publically
+        # using the current timestamp as the unique object ID
+        object_name = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+        logging.info("Begin upload to S3")
+        try:
+            s3.upload_file(
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Ah, got it - https://chatgpt.com/share/67d2dc20-59fc-8006-a092-5010cac7d62d
created_at: 2025-03-13T13:22:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,116 @@
+import json
+import logging
+import sys
+from datetime import datetime
+
+import boto3
+import paho.mqtt.client as mqtt
+from libcamera import Transform  # , controls
+from my_secrets import (
+    CAMERA_READ_ENDPOINT,
+    CAMERA_WRITE_ENDPOINT,
+    MQTT_HOST,
+    MQTT_PASSWORD,
+    MQTT_PORT,
+    MQTT_USERNAME,
+)
+from picamera2 import Picamera2
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler("device.log")],
+)
+logger = logging.getLogger("device")
+
+
+def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")
+            return
+
+        logging.info("Start image capture")
+        try:
+            picam2.capture_file(file_path)
+            logging.info("Successfully capture image")
+        except Exception as e:
+            logging.error(f"Error capturing image: {e}")
+
+        # make sure to setup S3 bucket with ACL and public access
+        # so that the link works publically
+        # using the current timestamp as the unique object ID
+        object_name = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S") + ".jpeg"
+        logging.info("Begin upload to S3")
+        try:
+            s3.upload_file(
+                file_path, BUCKET_NAME, object_name, ExtraArgs={"ACL": "public-read"}
+            )
+            logging.info("Successfully uploaded to S3")
+        except Exception as e:
+            logging.error(f"Error uploading to S3: {e}")
+
+        file_uri = f"https://{BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com/{object_name}"
+
+        try:
+            client.publish(CAMERA_WRITE_ENDPOINT, json.dumps({"image_url": file_uri}))
+            logging.info(f"Published {file_uri} to {CAMERA_WRITE_ENDPOINT}")
+        except Exception as e:
+            logging.error(
+                f"Error publishing {file_uri} to {CAMERA_WRITE_ENDPOINT}: {e}"
+            )
+
+
+if __name__ == "__main__":
+    # RPI Camera
+    logging.info("Configuring camera")
+    try:
+        picam2 = Picamera2()
+        picam2.set_controls({"AfMode": "auto"})
+        # JPEG quality level (0 is worst, 95 is best)
+        picam2.options["quality"] = 90
+        config = picam2.create_still_configuration(transform=Transform(vflip=1))
+        picam2.configure(config)
+        picam2.start()
+        logging.info("Successfully configured camera")
+    except Exception as e:
+        logging.error(f"Error configuring camera: {e}")
+        exit(1)
+
+    # AWS S3
+    logging.info("Configuring AWS S3")
+    try:
+        BUCKET_NAME = ""
+        AWS_REGION = ""
+        s3 = boto3.client("s3", region_name=AWS_REGION)
+        logging.info("Successfully configured AWS S3")
+    except Exception as e:
+        logging.error(f"Error configuring S3: {e}")
path: src/ac_training_lab/A1-cam/device.py
user: login: sgbaird
body: Would logging.exception make sense here? https://stackoverflow.com/questions/5191830/how-do-i-log-a-python-error-with-debug-information
created_at: 2025-03-13T13:25:37Z
author_association: MEMBER

PR comments:
user: login: Jonathan-Woo
created_at: 2025-03-01T00:07:34Z
body: So far no. Previously the HF timeout expired too early but that’s about it.
________________________________
From: Sterling G. Baird ***@***.***>
Sent: Friday, February 28, 2025 7:06:06 PM
To: AccelerationConsortium/ac-training-lab ***@***.***>
Cc: Jonathan Woo ***@***.***>; Author ***@***.***>
Subject: Re: [AccelerationConsortium/ac-training-lab] A1 cam updates (PR #178)


@sgbaird commented on this pull request.

________________________________

In src/ac_training_lab/A1-cam/device.py<https://github.com/AccelerationConsortium/ac-training-lab/pull/178#discussion_r1976135743>:

> +def on_message(client, userdata, message):
+    data = json.loads(message.payload)
+    command = data["command"]
+
+    if command == "capture_image":
+        logging.info(f"Received command: {command}")
+
+        # only keeping one local copy at any given time called image.jpg
+        file_path = "image.jpeg"
+
+        logging.info("Begin autofocus")
+        try:
+            picam2.autofocus_cycle()
+            logging.info("Successfully finished autofocus")
+        except Exception as e:
+            logging.error(f"Error autofocusing: {e}")


Did you run into errors here at any point?

—
Reply to this email directly, view it on GitHub<https://github.com/AccelerationConsortium/ac-training-lab/pull/178#pullrequestreview-2651983011>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AL3OVBLMKETJLVJZ6PPZJSL2SD2W5AVCNFSM6AAAAABYDMPSFWVHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHMZDMNJRHE4DGMBRGE>.
You are receiving this because you authored the thread.Message ID: ***@***.***>

author_association: CONTRIBUTOR
user: login: sgbaird
created_at: 2025-03-17T14:37:59Z
body: I did a bit of refactoring and modification. In particular, I added a dummy package that can be installed to emulate picamera2 and libcamera.

@Jonathan-Woo What instructions did you follow for installing libcamera? Could you include a link?

I'm assuming picamera2 and paho-mqtt were just via pip.
author_association: MEMBER
user: login: Jonathan-Woo
created_at: 2025-03-17T14:50:25Z
body: I don't fully remember but I believe libcamera was installed when I installed [picamera2](https://github.com/raspberrypi/picamera2) with the `--no-install-recommends` argument for minimal installation.
author_association: CONTRIBUTOR
user: login: sgbaird
created_at: 2025-03-17T18:33:51Z
body: Great! ~~What I found seems to confirm that, too~~. Thanks! (added some instructions to a README file)

EDIT: Looks like in a venv, there is [a special consideration](https://forums.raspberrypi.com/viewtopic.php?t=361758):

```
python3 -m venv --system-site-packages env
```

EDIT: However, OS Lite doesn't install pip, which requires it's own install command. Since venv comes with pip, maybe not a big deal (though I still lean somewhat towards using the default Python installation since it's fewer steps once set up).

EDIT: Since this complicates the cron job (e.g., I had installed venv rather than .venv), moving back to use of built-in Python and [manual installation of pip](https://forums.raspberrypi.com/viewtopic.php?t=305295):

```
sudo apt install python3-pip -y
```

EDIT:

```
ac@rpi-zero2w-a1mini-toolhead-camera:~/ac-training-lab/src/ac_training_lab/a1_cam $ sudo systemctl status a1-cam.service
● a1-cam.service - Start a1-cam device.py script
     Loaded: loaded (/etc/systemd/system/a1-cam.service; enabled; preset: enabled)
     Active: activating (auto-restart) (Result: exit-code) since Tue 2025-03-18 21:04:52 EDT; 3s ago
    Process: 1023 ExecStart=/usr/bin/python3 /home/ac/ac-training-lab/src/ac_training_lab/a1_cam/device.py (code=exited, status=1/FA>
   Main PID: 1023 (code=exited, status=1/FAILURE)
        CPU: 224ms
ac@rpi-zero2w-a1mini-toolhead-camera:~/ac-training-lab/src/ac_training_lab/a1_cam $ sudo journalctl -u a1-cam.service -f
Mar 18 21:05:02 rpi-zero2w-a1mini-toolhead-camera systemd[1]: a1-cam.service: Failed with result 'exit-code'.
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera systemd[1]: a1-cam.service: Scheduled restart job, restart counter is at 24.
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera systemd[1]: Stopped a1-cam.service - Start a1-cam device.py script.
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera systemd[1]: Started a1-cam.service - Start a1-cam device.py script.
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera python3[1031]: Traceback (most recent call last):
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera python3[1031]:   File "/home/ac/ac-training-lab/src/ac_training_lab/a1_cam/device.py", line 8, in <module>
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera python3[1031]:     import boto3
Mar 18 21:05:12 rpi-zero2w-a1mini-toolhead-camera python3[1031]: ModuleNotFoundError: No module named 'boto3'
Mar 18 21:05:13 rpi-zero2w-a1mini-toolhead-camera systemd[1]: a1-cam.service: Main process exited, code=exited, status=1/FAILURE
Mar 18 21:05:13 rpi-zero2w-a1mini-toolhead-camera systemd[1]: a1-cam.service: Failed with result 'exit-code'.
^C
ac@rpi-zero2w-a1mini-toolhead-camera:~/ac-training-lab/src/ac_training_lab/a1_cam $ ls
bookworm-tailscale-instructions.txt  dummy_pkg              my_secrets.py  requirements.txt
device.py                            my_secrets_example.py  README.md      _scripts
ac@rpi-zero2w-a1mini-toolhead-camera:~/ac-training-lab/src/ac_training_lab/a1_cam $ pip install -r requirements.txt 
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.
    
    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv path/to/venv.
    Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
    sure you have python3-full installed.
    
    For more information visit http://rptl.io/venv

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.
ac@rpi-zero2w-a1mini-toolhead-camera:~/ac-training-lab/src/ac_training_lab/a1_cam $ 
```

So, back to using venv... planning to just add final instructions to README to avoid having to update the issues over and over again.
author_association: MEMBER