React Native · AWS IoT · Raspberry Pi · Python

PowerLabs

ROAV

I designed and built the entire stack: a React Native mobile app, an AWS IoT cloud backend, and a Python edge runtime on Raspberry Pi controlling real hardware in the field.

RoleSole Engineer
StackMobile · Cloud · Edge · Hardware
StatusBeta
SolarUnitCard.js live
ROAV Unit 1 edit
Charging Bulk
94% State of Charge
Connected Connection
Actions
tracking
deployed
Battery Voltage 26.4 v
Inverter Output 120.0 v
Wind Speed 8 mph
PV Voltage 44.1 v
PV Buck Current 8.3 a
PV Power 367.0 w
Last updated: 2s ago
UnitActionsModal.js live
ROAV Unit 1 tracking · deployed
Retract
Go Home
Waypoint
Cancel

Biaxial & deployment state,
commanded from the app

Biaxial tracking
Deployment deployed
Scroll
01
React Native App

Built the
entire app.

Cross-platform iOS and Android in React Native + Expo SDK 51. AWS Amplify v6 handles auth (Cognito) and real-time data (AppSync GraphQL subscriptions), so the app receives live device state updates without polling.

The app has two modes that switch automatically. Online, it talks to AWS: live telemetry, command dispatch, production history. When the Pi goes offline, it broadcasts its own network and the app switches to direct local control. No manual intervention on either end, no cloud dependency required.

State Redux Toolkit + redux-persist. Selective slice persistence and rehydration across app restarts.
Auth Full Cognito flows: sign-up, email verify, login, MFA, password reset, all built from scratch.
Navigation React Navigation v6. Auth-gated stacks, tab nav, role-based routing for Roav vs MIDSS users.
Charts Victory Native + React Native Skia. Production charts for solar yield over time.
CI/CD EAS with dev, preview, and production build channels. OTA updates without App Store review.
02
Production.js · Victory Native

Energy yield,
every interval.

Register 0x010C on the Sigineer inverter holds PV input power in watts. The Pi polls it over Modbus RTU every 30 minutes, accumulates it into a running kWh total, and publishes the value to a dedicated AWS IoT named shadow (the Power Shadow), separate from the mechanical shadow that handles commands.

An IoT Core rule triggers Lambda on each shadow update, which writes the reading and timestamp to DynamoDB. The app subscribes via AppSync GraphQL and renders daily production in 30-minute intervals using Victory Native with React Native Skia as the rendering backend.

Inverter reg 0x010C PV watts · Sigineer RS485
Modbus RTU · 30 min poll
Power Shadow named shadow · MQTT
IoT Rule → Lambda
DynamoDB kwh · timestamp · unitId
AppSync subscription
Production.js Victory Native · Skia
Production.js live
Production (All Time)
1,247 kWh
Time Deployed (All Time)
312.4 hours
Deployments
89
This Month · May
187.4 kWh
41.2 hrs deployed
7 14 30 90

Last 14 Days

5/17
5/18
5/19
5/20
5/21
5/22
5/23
5/24
5/25
5/26
5/27
5/28
5/29
5/30
14.0 7.0 0
May 29
14.0 kWh
May 30
6.5 kWh
May 28
12.4 kWh
May 24
13.1 kWh

Real time PV metrics

PV Voltage 44.1 v
PV Buck Current 8.3 a
PV Power 367.0 w
03
Raspberry Pi · Python · Flask

Five services.
One device.

Each service has its own systemd unit file, restart policy, and failure domain. One crashing does not take down the others. The system self-heals without intervention.

systemctl status
roav-main.service Motor control · sun tracking · shadow subscriptions
active
roav-inverter.service Modbus RTU polling · telemetry
active
roav-jobs.service AWS IoT Jobs · OTA update handler
active
roav-forecasting.service AI predictions for energy production
active
roav-network.service Connection monitor · AP/client switching
active
roav-inverter.service
Modbus RTU · Sigineer inverter

Polling over RS485 serial via pymodbus. Each register address maps to a specific inverter data point, scaled by a fixed factor defined in the inverter's protocol spec.

0x0101 Battery voltage × 0.1 V
0x0102 Battery SOC %
0x010C PV input power W
0x010D Output load power W
0x0109 Grid voltage × 0.1 V
roav-network-monitor.service offline control
Flask + SQLite IPC bridge

Flask and the robot runtime are separate OS processes with no shared memory. SQLite acts as a durable command queue between them: no Redis, no sockets, no external broker.

Flask port 5000
INSERT INTO commands
SQLite commands table
SELECT · poll loop
roav-main asyncio runtime
04
AWS · GraphQL · IoT Core

The cloud
layer.

GraphQL over REST was a deliberate call: the goal was real-time features from the start, and AppSync's subscription model made that straightforward rather than something bolted on later. AppSync and IoT Core then solve different problems: AppSync for the app's query and subscription model, IoT Core for device state and command delivery. Using both means the app never polls: state changes arrive as subscriptions, commands travel down as shadow deltas. The rules engine closes the loop: every time a Pi reports state back, it fires Lambda to write to GraphQL, keeping the app in sync without a custom server in the middle.

DynamoDB · Data Model us-east-1
SolarUnits 3 items
PK unitId String
unitNameS
ownerIdS
deploymentStateS
biaxialStateS
lastSeenN
firmwareVersionS
roav-unit-001 · ROAV Unit 1 · deployed
Logs 14.2k items
PK unitId String
SK timestamp Number
levelS
messageS
serviceS
ttlN
roav-unit-001 · INFO · shadow delta received
Production 8.9k items
PK unitId String
SK intervalTs Number
kwhN
pvVoltageN
pvPowerN
batteryVoltageN
batterySocN
roav-unit-001 · 14.0 kWh · 367 W
Users 7 items
PK userId String
emailS
roleS
unitIdsSS
cognitoSubS
createdAtN
usr-7f3a... · admin · 2 units
AWS IoT Core · Named Shadows roav-unit-001
power in sync
…/shadow/name/power
pvPower 367 W kwhTotal 1,247
mechanical Δ delta
…/shadow/name/mechanical
biaxial tracking deployment deployed
environment in sync
…/shadow/name/environment
windSpeed 8 mph gps 34.05°N
connectivity in sync
…/shadow/name/connectivity
internet online mode client
forecasting in sync
…/shadow/name/forecasting
peakHour 13:00 fcastKwh 14.2

Command flow: what happens when you press a button.

From GraphQL mutation to physical hardware movement, end to end.

1
Mobile App App

User presses a control button: a GraphQL mutation fires via AWS Amplify, writing the command to the Actions table in DynamoDB via the AppSync resolver.

2
DynamoDB Stream → Lambda Cloud

The new Actions record triggers a DynamoDB stream event. Lambda fires with the solar unit and Thing ID, then calls UpdateThingShadow via the IoT Data API, writing the command into the shadow's desired state.

3
AWS IoT Core · Device Shadow Cloud

IoT Core computes the delta between desired and reported state and publishes it to the device's MQTT shadow delta topic.

MQTT over TLS · $aws/things/{id}/shadow/update/delta → device
4
Raspberry Pi · asyncio service Edge

The asyncio service subscribes to the shadow delta topic over MQTT. When a delta arrives it parses the desired state and dispatches a command to the motor control coroutine.

5
ROAV · Roboclaw + actuators Hardware

Commands sent to the Roboclaw motor controller over serial. Linear actuators extend or retract, physically driving the frame to the target azimuth or altitude.

6
Raspberry Pi · position confirmed Edge

Movement complete. Pi publishes the new position and status back to $aws/things/{id}/shadow/update, setting reported state to match what just happened.

← cloud IoT Core rules engine · shadow update triggers Lambda
7
AWS Lambda Cloud

An IoT Core rule fires Lambda on the reported shadow update. Lambda reads the new device state and calls an AppSync GraphQL mutation.

8
AppSync · DynamoDB Cloud

GraphQL mutation writes the confirmed device state and timestamp to DynamoDB. AppSync resolves the write and notifies any active subscriptions.

9
Mobile App · state updated App

The GraphQL subscription fires on the mobile client. Redux state updates, the UI re-renders, and the user sees the confirmed new ROAV position with no polling required.

05
Engineering challenges

What I had
to figure out.

Problems that did not have obvious answers: they only surface when you're dealing with real hardware.

Two UIs, one product

Normally the React Native app runs against AppSync and Cognito as expected. When the Pi loses internet, roav-network-monitor switches it into AP mode automatically. The React Native app embeds a WebView: connect the phone to the Pi's hotspot and it loads the Flask UI for direct hardware control. When internet returns, the Pi switches back to client mode. No manual intervention on either end.

Cross-process IPC without a message broker

Flask and the asyncio robot runtime are separate OS processes: they can't call each other directly. The obvious answer is a message broker, but that means another process competing for RAM on a Pi that's already running five services. A SQLite commands table does the job instead: Flask INSERTs a row, the runtime polls and executes it. No broker, no socket protocol to define, and commands survive a service restart because they're already on disk.

Serial protocol reverse engineering

The Sigineer inverter speaks Modbus RTU over RS485. Mapped the register addresses for different data points (pv power, pv voltage, battery voltage, SOC), handled scaling factors, and built a typed data model from raw uint16 register reads.

local.powerlabs / setup
Flask onboarding UI

Web screenshot