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.
Biaxial & deployment state,
commanded from the app
Login
Don't have an account?
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.
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.
Last 14 Days
Real time PV metrics
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.
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.
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.
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.
Command flow: what happens when you press a button.
From GraphQL mutation to physical hardware movement, end to end.
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.
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.
IoT Core computes the delta between desired and reported state and publishes it to the device's MQTT shadow delta topic.
$aws/things/{id}/shadow/update/delta → device 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.
Commands sent to the Roboclaw motor controller over serial. Linear actuators extend or retract, physically driving the frame to the target azimuth or altitude.
Movement complete. Pi publishes the new position and status back to $aws/things/{id}/shadow/update, setting reported state to match what just happened.
An IoT Core rule fires Lambda on the reported shadow update. Lambda reads the new device state and calls an AppSync GraphQL mutation.
GraphQL mutation writes the confirmed device state and timestamp to DynamoDB. AppSync resolves the write and notifies any active subscriptions.
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.
What I had
to figure out.
Problems that did not have obvious answers: they only surface when you're dealing with real hardware.
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.
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.
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.