Device-to-cloud camera workflow
AWSCam was my CMP408 IoT and Cloud project. I built a Raspberry Pi system that could take a photograph in response to either a physical button press or a request from a web browser, upload the image to AWS, and retrieve the stored photographs through the same browser interface.
The project had three implementation objectives:
- develop a kernel-level interface for the button and status LED
- capture and upload images from a userspace service
- support remote capture and image viewing through AWS
The completed path crossed kernel and userspace boundaries on the Raspberry Pi, then continued through MQTT, HTTPS, API Gateway, Lambda, S3, and browser-side JavaScript. I treated the physical button and remote request as two inputs to the same capture function so the image handling did not split into separate implementations.

GPIO interrupt and character device
The physical build used a Raspberry Pi, push button, LED, jumper wires, and two resistors. GPIO 16 received the button input and GPIO 23 drove the LED used as a capture indicator.
I wrote buttonDriv.c as a loadable Linux kernel module. Its initialisation routine:
- checked that both GPIO identifiers were valid
- requested the LED and button pins
- configured the LED as an output and the button as an input
- applied button debounce handling
- mapped the button GPIO to an interrupt number
- registered a rising-edge interrupt handler
- created a device class and character device named
/dev/button_device
The interrupt handler protected the shared button state with a mutex, changed the state when a press arrived, and updated the LED. The character device used a dynamically allocated major number and a file_operations structure with open, release, and unlocked_ioctl handlers.
I exposed three ioctl operations:
- switch the LED on
- switch the LED off and reset the stored button state
- copy the current button state back to userspace
The command values were shared between the C module and Python service, giving both sides the same small protocol. IOCTL_READ_BUTTON_STATE copied a C integer through the file descriptor into a ctypes.c_int; the other two operations had no payload. The Python helper opened the device only for the duration of each call.
The open and release handlers tracked whether the device was already in use and held the kernel module while the character device was open. On unload, the module released the IRQ, unexported and freed both GPIO pins, destroyed the device and class, and unregistered the character device.
This kept GPIO ownership in the driver while giving the Python service a compact userspace interface. Python could poll one integer state and control the indicator without writing directly to the GPIO filesystem.
The source review also identified several driver changes I would make before treating the interface as robust:
- the interrupt handler takes a mutex, but a hard-interrupt path must not use a sleeping lock; an atomic state, spinlock, or deferred work item would be appropriate
- the handler toggles state on every rising edge, so electrical bounce or repeated edges can invert the intended meaning even with the requested debounce interval
DevBusyis an unprotected byte, leaving the single-open check vulnerable to concurrent opens- the
copy_to_user()error test sits after an unconditionalbreakand is therefore unreachable - initialisation failures after GPIO or IRQ allocation do not unwind every resource acquired earlier
gpio_set_debounce()depends on controller support and its result is not checked
These are not theoretical style points. They sit on the boundary between an asynchronous hardware event and a userspace consumer, where missed cleanup or unsafe locking can affect the whole kernel rather than only one process.
Camera integration through V4L2
The intended Raspberry Pi camera module was incompatible with the available board and software configuration. I used an iPhone 12 camera through DroidCam and an adjusted V4L2 loopback module instead. DroidCam was a third-party dependency; my own service started it, consumed its video device, and connected the result to the rest of the project.
AWSCam.py launched the DroidCam command-line client as a subprocess with the camera connection values supplied to the Python programme. OpenCV then opened video device 0, read one frame, and released the device. I converted the frame from OpenCV’s BGR representation to RGB, created a Pillow image, and wrote the JPEG into an in-memory BytesIO buffer. The service terminated the DroidCam subprocess after capture rather than keeping the network camera active between requests.
Using V4L2 kept the capture code independent of the phone-specific transport. Once DroidCam exposed the stream as a standard Linux video device, OpenCV could process it in the same way as another local camera source.
The submitted implementation started DroidCam and attempted to open the video device immediately. It did not wait for device readiness. If VideoCapture failed to open, the function returned without terminating the subprocess; exceptions could leave the camera process running for the same reason. A stronger capture wrapper would use try/finally, wait for the V4L2 device with a timeout, verify the selected device rather than assuming index 0, and terminate or kill the child process predictably.
Only one frame was read. That was enough for the demonstration, but the first available frame after starting a network camera may be stale, incomplete, or poorly exposed. A production capture path could discard initial frames, apply a resolution and quality policy, record capture metadata, and validate the encoded output before uploading it.
Python control loop
The userspace service opened /dev/button_device and used fcntl.ioctl to read the state and control the LED. It also created an AWS IoT MQTT client, connected with the device certificate material from the lab, and subscribed to the AWSCam topic at QoS 0.
The main loop checked the button state once per second. A true state called snapshot(), while the MQTT callback called the same function when it received a snapshot message. The function then:
- switched on the status LED
- started the camera process
- captured and encoded one JPEG frame
- generated a filename from the date and time
- wrapped the Base64 image, filename, and
uploadmethod in JSON - posted the request to API Gateway over HTTPS
- switched off the LED and reset the stored button state
The local and remote paths could invoke snapshot() from different execution contexts. The source did not serialise capture operations, so two near-simultaneous requests could start competing camera processes, use the same device, and change the LED or button state underneath each other. A capture queue or lock would make one component responsible for state transitions and allow additional requests to be rejected, coalesced, or processed later.
The HTTP request also had no explicit connection or read timeout and no retry or idempotency identifier. If the upload stalled, the status LED and button state remained tied to the blocking request. Separating capture from upload would let the device save a local artefact first, acknowledge the button event, then retry cloud delivery without taking another photograph.
The submission source contains expired teaching endpoints and device-certificate filenames. They are deployment remnants rather than reusable project configuration, so they are not reproduced here.
Three AWS request paths
API Gateway passed browser and device requests to one Python Lambda function. The handler parsed the request body and routed it by a method field.
Remote snapshot
The browser’s Snapshot button sent a snapshot request. Lambda published a JSON message to the AWSCam AWS IoT topic, which triggered the callback in the Raspberry Pi service. The image returned through a separate upload request rather than through the MQTT response.
This was an asynchronous request even though the browser used an ordinary HTTP call. A successful API response meant that the MQTT message had been published; it did not mean the camera had captured or uploaded a photograph. The interface had no correlation identifier to connect the browser request with the later S3 object and no device acknowledgement to show whether the Pi was online.
Image upload
The Raspberry Pi sent an upload request containing the timestamped filename and Base64 image. Lambda decoded the content and used the S3 client to create an object in the project bucket. S3 therefore held the photograph as binary image data rather than retaining the transport encoding.
The timestamp used the Raspberry Pi’s local clock to the nearest second. Two captures within one second could therefore select the same key and overwrite an object. The Lambda trusted the supplied filename and image content, did not enforce an extension or decoded-size limit, and did not attach a verified content type. A generated object identifier plus separate capture metadata would avoid using a client-controlled key as the record identity.
Image listing
The browser’s List button sent a list request. Lambda enumerated the objects in S3, selected recognised image extensions, read each object, converted it to Base64, and returned an array of filename-and-data objects.
The JavaScript cleared the current image container, parsed the response, created an element for each result, set a Base64 data URL as the image source, and appended the filename and image to the page.
The Lambda used one list_objects_v2 response and did not follow continuation tokens, so it would stop at the service’s page limit. It then downloaded every matching object synchronously and placed every encoded image into one API response. The browser declared every returned image as JPEG even though the Lambda also admitted PNG and GIF keys.
This worked for a handful of coursework images. As the bucket grew, execution time, Lambda memory, S3 reads, Base64 expansion, API response limits, browser memory, and page-rendering time would all grow together.
The AWS teaching environment restricted IAM changes, which ruled out the direct object-access design I initially considered. Returning image data through Lambda let the browser work within the available permissions, but it coupled retrieval cost and response size to the number and size of stored images.
End-to-end behaviour
The physical and remote paths converged at the same Python function:
button interrupt -> character device -> Python snapshot
browser -> API Gateway -> Lambda -> MQTT -> Python snapshot
Python snapshot -> HTTPS upload -> API Gateway -> Lambda -> S3
browser -> API Gateway -> Lambda -> S3 list -> rendered images
During submission testing, the driver registered button events and controlled the LED, the userspace service captured from the V4L2 device, and both local and MQTT requests invoked the image path. The AWS components accepted uploads, stored the JPEG objects, and returned them to the browser.
The test evidence covered each boundary separately:
- kernel log output confirmed driver loading, IRQ mapping, button events, and
ioctlcalls - the LED showed capture state at the device
- OpenCV and Pillow produced a JPEG byte stream from the V4L2 source
- the MQTT subscription received the Lambda-published snapshot message
- the API upload returned a successful response
- the object appeared in S3 under its timestamped name
- the browser list request rendered the stored object
That sequence was more informative than a final screenshot because it identified where to start when one stage failed.
The workaround camera performed better than I expected, but it introduced another process and a network dependency into every capture. Error handling was also local to each stage. A camera failure returned no frame, an upload failure printed the HTTP response, and an MQTT connection failure stopped the service initialisation. The implementation did not yet have a queue, retry policy, offline buffer, or central device-health state.
Security and reliability review
The project was built in a temporary AWS teaching environment, and several areas would need redesign for a deployed camera:
- Browser requests needed explicit user authentication and authorisation. A remote capture action should not be available solely because a caller knows an endpoint.
- Device certificates and private-key files needed controlled provisioning, rotation, and filesystem permissions rather than static lab configuration.
- S3 access should remain private, with narrowly scoped Lambda permissions and short-lived access for authorised viewers.
- The API needed request-size limits, schema validation, rate controls, and clearer error responses.
- The MQTT topic and IoT policy should restrict the device and Lambda to their required publish and subscribe actions.
- Image retention, deletion, metadata, and audit logging would need policy because photographs can contain sensitive physical-security information.
- Polling the character device once per second worked for the prototype but consumed repeated syscalls and could miss opportunities for cleaner event handling.
- The kernel module’s state model was intentionally compact. A deployed driver would need more complete concurrency, failure-path, and interface testing.
- The Lambda router needed validation for missing or unknown methods rather than returning no response path.
- Exceptions should be logged with a request or capture identifier while avoiding certificate paths, image content, and other sensitive data.
Base64 also expands binary data and makes the list operation increasingly expensive. Returning every stored image in one response is unsuitable once the bucket grows beyond a small demonstration set.
Further engineering work
On the Raspberry Pi, I would replace polling with an event-driven userspace notification, keep capture and upload in separate workers, and integrate the camera transport without starting an external process for each photograph. The submitted report proposed a C++ userspace service for lower overhead; the more important design change would be clear event ownership and resilience when either the camera or network is unavailable.
On AWS, I would return paginated object metadata and generate short-lived access URLs only after an authorised request. That would remove the bulk Base64 response, keep objects private, and allow the browser to request individual photographs. Device health, failed uploads, and capture requests would also need structured logs and metrics.
I would also make the remote workflow explicit:
browser creates capture request with request ID
-> authorised API stores request state
-> IoT message targets one device
-> device acknowledges, captures, and uploads with the same ID
-> object event marks the request complete
-> browser polls or receives completion and requests a short-lived image URL
That design distinguishes “message accepted” from “photograph stored”, supports retries without duplicate captures, and leaves an audit trail across the device and cloud services.
AWSCam joined a custom kernel interface, Python orchestration, physical input, image processing, MQTT, an HTTPS API, serverless routing, object storage, and a browser into one tested workflow. It gave me practical experience of the boundaries where an IoT system usually fails: hardware events, userspace state, network availability, cloud permissions, and the amount of data moved between each component.
