Superstack IoT Platform
Preliminary
This page is actively being updated. Information may change, so be sure to check back frequently.
Superstack is a cloud IoT platform that lets you deploy, program, and manage connected devices from anywhere. You can update firmware, monitor data in real-time, and run AI-powered analytics, all through a secure web dashboard or API. Superstack handles connectivity, device onboarding, and fleet management, so you can focus on building your application. Its natural language query engine and easy business integration make it simple to turn raw device data into actionable insights at scale.
Contents
Connecting your first module
To connect an S2 Module to Superstack:
- Create or select a Deployment that you wish to add the Device to
- Navigate to the Devices Tab within Superstack
- Press Add device
- Power on your Device using either USB, battery, or external power
- Wait until the network LED stops blinking and goes solid, indicating that it has successfully connected to an LTE network
- Enter the Device IMEI you wish to connect to
- Press Add device at the bottom of the dialog
- Click the button on your Device to complete pairing
- The Device should now show up within the Devices tab
The following video shows detailed step by step instructions on how to connect your Device to Superstack.
Deployments, devices & users
Deployments are the top level entities within Superstack. You can think of them as similar to “projects”. A Deployment can contain multiple Devices up to a limit determined by the Subscription Plan.
A Device can only be connected to a single Deployment at a time. The data usage from all Devices is combined into a single pool where the total limit is determined by the Subscription Plan.
A Deployment may have multiple Users. Subscription Plans are Deployment based, therefore a user may be a part of multiple Deployments. Users can have different permissions, which restrict their access to what they can do within that Deployment.
- Owners - Have full access to all features and can manage other Users. There can only be one owner for a Deployment
- Device managers - Can add or remove Devices, as well as manage Device Details
- Developers - Can edit Code for Devices, but don’t have permissions to manage Device Details
- Viewers - Can view the Deployment including code and Device details, but cannot edit anything
All Users have access to the AI Agent functionality. A Deployment may additionally be made Public allowing anyone with a link to the Deployment access to view the Devices and access the AI Agent.
Device details
The Devices tab lets you add Devices, as well as monitor and manage your fleet. Here you can check which Devices are online, as well as total data usage.
Clicking on a specific Device will reveal the Device Details panel. This panel allows you to edit the Device Name, Group and AI role. All three of these help the AI Agent understand the role of the specific Device, so they should be populated in reasonable detail, avoiding ambiguity.
Unpairing devices
A Device can only be paired to one Deployment at a time. In order to move it to a new Deployment, it must be removed from the original Deployment that it’s paired to.
Scrolling to the bottom of the Device Details panel will reveal the Remove device button. Follow the instructions to un-pair the Device. Only Owners and Device Managers can remove Devices.
Deleting a Deployment will un-pair all Devices.
Managing your subscription
Details coming soon
Lua programming & libraries
All Superstack-compatible Devices, including the S2 Module, run a unified firmware that includes the Lua 5.4.7 runtime. A powerful yet simple scripting engine that can be picked up easily by both new and seasoned programmers alike.
This engine allows Devices to be programmed remotely to run scripts that gather data from sensors, process it, and then return data to Superstack. All the exposed Lua functions are hardware-accelerated under the hood, allowing for performance close to that of bare metal in C.
Thanks to this scriptable nature, applications can be iterated and debugged incredibly quickly without having to maintain any local development tools or wait for compilations to complete. Best of all, this can all be done remotely, even with Devices that may be located across countries or continents.
Standard Lua libraries
Many of the standard libraries are included for the user to take advantage of.
- ✅ Basic functions
- ✅ Math functions
- ✅ String manipulation
- ✅ Table manipulation
- ✅ UTF-8 support
- ✅ Coroutine manipulation
- ✅ Debug library
Standard libraries which are not included are superseded by similar Device specific libraries:
- ❎ File IO functions - Replaced by the non-volatile memory library
- ❎ Operating system functions - Replaced by the timekeeping and Device libraries
The following Device-specific libraries allow access to all aspects of the Device I/O and feature set such as LTE-based communication and GPS. Additionally, some other libraries provide convenient functions for running DSP operations and type conversions.
These functions may accept a combination of positional and named arguments. Named arguments are passed as tables which essentially expect key-value pairs. Most of these keys will take on a default value if not provided. These are marked as optional.
-- Calling a function with only positional arguments
device.digital.set_output("A0", true)
-- Calling a function with both positional and named arguments
device.i2c.write(0x12, 0x4F, "\x01", { scl_pin="B0", sda_pin="B1"})
-- Calling a function with only positional arguments. Note how the () can be omitted
network.send{ sensor_value=31.5 }
Digital IO
Function | Details |
---|---|
device.digital.set_output(pin, value) | Sets or clears a digital output on a pin. Parameters:
|
device.digital.get_input(pin, { pull="PULL_DOWN" }) | Gets the digital value on a pin. Parameters:
|
device.digital.assign_input_event(pin, handler, { pull="PULL_DOWN" }) | Assigns an event handler that triggers whenever the input value of a pin changes. Parameters:
|
device.digital.unassign_input_event(pin) | Disables the event and detaches the pin from the handler. Parameters:
|
Example usage:
-- Set pin B1 to a high value
device.digital.set_output("B1")
-- Print the value on pin D0
local val = device.digital.get_input("D0")
print(val)
-- Assign a function that triggers whenever the input value of C3 changes
function my_pin_handler(pin, state)
if state == true then
print(pin.." went high")
else
print(pin.." went low")
end
end
device.digital.assign_input_event("C3", my_pin_handler)
-- Disable the event and detach the pin from the handler if no longer needed
device.digital.unassign_input_event("C3")
Analog input
Function | Details |
---|---|
device.analog.get_input(pin, { acquisition_time=40 }) | Reads the analog value on an analog-capable pin. Parameters:
|
device.analog.get_differential_input(positive_pin, negative_pin, { acquisition_time=40 }) | Reads the analog value across two analog capable pins. Parameters:
|
Example usage:
-- Read the analog value on pin D1 and print both the percentage and voltage values
local d0_val = device.analog.get_input("D1")
print(d0_val.percentage)
print(d0_val.voltage)
-- Trigger a print whenever the voltage on D2 drops below 1.5V
function my_low_voltage_handler(pin, exceeded)
if (exceeded) then
print("Voltage fell below 1.5V")
else
print("Voltage has returned back above 1.5V")
end
end
device.analog.assign_input_high_event("D1", my_low_voltage_handler, { voltage=1.5 })
-- Disable the event and detach the pin from the handler if no longer needed
device.analog.unassign_input_event("D1")
PWM output (analog output)
Function | Details |
---|---|
device.analog.set_output(pin, percentage { frequency=1 }) | Sets a PWM duty cycle on a pin. Parameters:
|
Example usage:
-- Set pin E1 to a 25% duty cycle at the default PWM frequency
device.analog.set_output("E1", 25)
I2C communication
Function | Details |
---|---|
device.i2c.read(address, register, length, { port="PORTA", scl_pin="A0", sda_pin="A1", frequency=400, register_address_size=8 }) | Reads a number of bytes from a register on an I2C connected device. Parameters:
|
device.i2c.write(address, register, data, { port="PORTA", scl_pin="A0", sda_pin="A1", frequency=400, register_address_size=8 }) | Writes a number of bytes to a register on an I2C connected device. Parameters:
|
device.i2c.scan({ port="PORTA", scl_pin="A0", sda_pin="A1", frequency=400 }) | Scans the given port for all connected I2C devices. Optional parameters:
|
Example usage:
-- Read a byte from register 0x1F on a device with address 0x23
local result = device.i2c.read(0x23, 0x1F, 1)
if result.success then
print(result.value)
end
-- Read multiple bytes from the device and print the fourth byte
local result = device.i2c.read(0x23, 0x1F, 4)
if result.success then
print(result.data[4])
end
-- Write 0x1234 to the register 0xF9
device.i2c.write(0x23, 0xF9, "\x12\x34")
-- Scan a port for devices
local d = device.i2c.scan({port="PORTF"})
print("Found " .. tostring(#d) .. " devices")
SPI communication
Function | Details |
---|---|
device.spi.write_read(write_data, read_length, { sclk_pin="C0", mosi_pin="C1", miso_pin="C2", cs_pin="C3", mode=0, frequency=4000, bit_order="MSB_FIRST", hold_cs=false, cs_active_high=false, miso_pull="NO_PULL" }) | Writes data to an SPI connected device, and then reads back a number of bytes. The total number of bytes clocked by the SPI will therefore be #write_data + read_length Parameters:
|
device.spi.write(data, { sclk_pin="C0", mosi_pin="C1", miso_pin="C2", cs_pin="C3", mode=0, frequency=4000, bit_order="MSB_FIRST", hold_cs=false, cs_active_high=false, miso_pull="NO_PULL" }) | Writes data to an SPI connected device, and then reads back a number of bytes. The total number of bytes clocked by the SPI will therefore be #write_data + read_length Parameters:
|
device.spi.read(length, { sclk_pin="C0", mosi_pin="C1", miso_pin="C2", cs_pin="C3", mode=0, frequency=4000, bit_order="MSB_FIRST", hold_cs=false, cs_active_high=false, miso_pull="NO_PULL" }) | Writes data to an SPI connected device, and then reads back a number of bytes. The total number of bytes clocked by the SPI will therefore be #write_data + read_length Parameters:
|
device.spi.transact(write_data, read_length, { sclk_pin="C0", mosi_pin="C1", miso_pin="C2", cs_pin="C3", mode=0, frequency=4000, bit_order="MSB_FIRST", hold_cs=false, cs_active_high=false, miso_pull="NO_PULL" }) | Writes and reads data to an SPI connected device in parallel. The total number of bytes clocked by the SPI will therefore be whichever is larger of #write_data or read_length Parameters:
|
Example usage:
-- An example of how to write and read to an SPI flash device
-- Wake up the flash with the 0xAB command
device.spi.write("\xAB")
device.sleep(0.1)
-- Disable write protection
device.spi.write("\x06")
-- Erase the flash. Can take a while
device.spi.write("\x60")
device.sleep(30) -- Alternatively keep checking the status until done
-- Disable write protection again
device.spi.write("\x06")
-- Write some data starting at address 0
device.spi.write("\x02\x00\x00\x00Hello world")
-- Read the data back
local data = device.spi.write_read("\x03\x00\x00\x00", 11)
print(data)
UART communication
Function | Details |
---|---|
device.uart.write(data, { baudrate=9600, tx_pin="B1", cts_pin=nil, parity=false, stop_bits=1 }) | Writes UART data to a pin. Parameters:
|
device.uart.assign_read_event(terminator, handler, { baudrate=9600, rx_pin="B0", tx_pin="B1", rts_pin=nil, cts_pin=nil, parity=false, stop_bits=1 }) | Buffers UART data from a pin, and triggers an event whenever a specified terminating character is seen. Parameters:
|
device.uart.unassign_read_event(rx_pin) | Disables the event and detaches the pin from the handler. Parameters:
|
Example usage:
-- Create a handler for receiving UART data
function my_receive_handler(data)
print("Got a new line: "..data)
end
device.uart.assign_read_event("\n", my_receive_handler, { baudrate=19200 })
-- Send UART data
device.uart.write("Hello there. This is some data\n", { baudrate=19200 })
-- Disable the event and detach the pin from the handler if no longer needed
device.uart.unassign_read_event("B0")
PDM microphone input
Function | Details |
---|---|
device.audio.record(length, handler, { data_pin="E0", clock_pin="E1", sample_rate=8000, bit_depth=8 }) | Begins recording microphone input and outputs the data to an event handler. The handler is called every length seconds and repeats until the stop() function is called. Parameters:
|
device.audio.stop(data_pin) | Stops recording samples and calls the event handler one last time with any remaining data in the internal buffer. Parameters:
|
Example usage:
-- Create a handler for receiving and processing audio samples
function my_microphone_handler(data)
for i = 1, #data do
local value = string.sub(data, i, i)
if value > 128 or value < -128 then
print("Loud noise detected")
end
end
end
-- Will capture 1s worth of audio at a time
device.audio.record(1, my_microphone_handler)
-- Recording can be stopped at any time
device.audio.stop("E0")
Sleep, power & system info
Function | Details |
---|---|
device.sleep(time) | Puts the device into a low-power sleep for a certain amount of time. Parameters:
|
device.power.battery.set_charger_cv_cc(voltage, current) | Sets the termination voltage and constant current values for the charger. Parameters:
|
device.power.battery.get_voltage() | Gets the voltage of the cell. Returns:
|
device.power.battery.get_charging_status() | Gets the charging status of the cell. Returns:
|
device.power.battery.set_vout(voltage) | Sets the voltage of VOUT. Parameters:
|
Constant | Details |
---|---|
device.HARDWARE_VERSION | The hardware version of the device. Returns:
|
device.FIRMWARE_VERSION | The current firmware version of the Superstack firmware running on the device. Returns:
|
Example usage:
-- Print the hardware version, sleep for 1.5 seconds and then print firmware version
print(device.HARDWARE_VERSION)
device.sleep(1.5)
print(device.FIRMWARE_VERSION)
-- Configure the battery charger for a 4.2V 200mAh rated Li-Po cell
device.power.battery.set_charger_cv_cc(4.2, 200)
-- Get the current battery status
local voltage = device.power.battery.get_voltage()
local status = device.power.battery.get_charging_status()
if status ~= "external_power" then
print("Battery is "..status)
print("Battery voltage is "..tostring(voltage).."V")
else
print("Battery not connected. On external power")
end
-- Set the voltage of Vout to 3.3V
device.power.set_vout(3.3)
Networking (LTE)
Function | Details |
---|---|
network.send{ data } | Sends data to Superstack. Parameters:
|
Example usage:
-- A simple sensor value
my_sensor_value = 23.5
network.send{ temperature=my_sensor_value }
-- Network send can contain any arbitrary data
network.send{
some_int = -42,
some_float = 23.1
some_string = "test"
some_array = {1, 2, 3, 4},
some_nested_thing = {
another_int = 54,
another_string = "test again"
}
}
Location (GPS)
Function | Details |
---|---|
location.get_latest() | Returns the latest GPS data. Returns:
|
location.set_options({ accuracy="HIGH", power_saving="MEDIUM", tracking_interval=1 }) | Sets options related to the GPS module. Optional parameters:
|
Example usage:
location.set_options({ accuracy = "LOW" })
while true do
local l = location.get_latest()
print("valid: " .. tostring(l["valid"]))
print("latitude: " .. tostring(l["latitude"]))
print("longitude: " .. tostring(l["longitude"]))
print("altitude: " .. tostring(l["altitude"]))
print("accuracy: " .. tostring(l["accuracy"]))
print("speed: " .. tostring(l["speed"]))
print("speed_accuracy: " .. tostring(l["speed_accuracy"]))
print("satellites tracked: " .. tostring(l["satellites"]["tracked"]))
print("satellites in fix: " .. tostring(l["satellites"]["in_fix"]))
print("satellites unhealthy: " .. tostring(l["satellites"]["unhealthy"]))
-- Note that LTE network activity (including logging) will interrupt the
-- GPS and delay attaining a fix. Therefore the device should avoid
-- network activity for a brief period of time while waiting for a fix
device.sleep(30)
end
File storage
Function | Details |
---|---|
storage.write(filename, data) | Creates a file and writes data to it. If the file already exists, it is overwritten. Parameters:
|
storage.append(filename, data) | Creates a file and writes data to it. If the file already exists, the new data is appended to the current contents of the file. Parameters:
|
storage.read(filename, { line=-1, length=nil, offset=0 }) | Reads out the contents of the file. Either returning an entire "\n" terminated line, or alternatively, a number of bytes with length length at an offset of offset . Parameters:
|
storage.delete(filename) | Deletes a file if it exists. Parameters:
|
storage.list() | Lists all the files stored on the device as a list of tables containing the filename and size. Returns:
|
Example usage:
-- Create a file and read it
storage.write("my_file.txt", "Hello world")
print(storage.read("my_file.txt"))
-- Append more data to the file
storage.append("my_file.txt", "\nThis is another line of text")
storage.append("my_file.txt", "\nAnd this is a final line of text")
-- Print the last line from the file
print(storage.read("my_file.txt", { line=-1 }))
-- Delete the file
storage.delete("my_file.txt")
Timekeeping
Function | Details |
---|---|
time.get_unix_time() | Returns the current Unix timestamp (i.e the number of non-leap milliseconds that have elapsed since 00:00:00 UTC on 1 January 1970), or if the time is not yet known, returns the uptime of the device in milliseconds. Returns:
|
time.get_time_date({unix_epoch, timezone}) | Gets either the current time and date information, or the time and date for a specified Unix timestamp. Optional parameters:
|
Example usage:
-- Repeat something for 30 seconds
local t = time.get_unix_time()
while t + 30 > time.get_unix_time() do
print("waiting")
device.sleep(5)
end
-- Print the current time and date
local now = time.get_time_date()
print(string.format("The current time is: %02d:%02d", now.minute, now.hour))
print(string.format("The current date is: %d/%d/%d", now.year, now.month, now.day))
AI agent
The Agent Tab lets you query your Devices and Data using natural language. It’s additionally capable of running advanced statistical analysis using its reasoning capabilities.
The AI Agent takes its content from a number of sources.
- Agent Role - Determines the overall goal of the AI Agent
- Device Name - Informs the AI Agent what the specific Device is monitoring
- Device Group - Informs the AI Agent how the various Devices may be grouped together
- Device Role - Details specifics around the Device, its sensors and additional information which may be useful analysis
- Data Schema - Extracted from the Data within the Data Tab where the keys of the JSON, as well as the types can help reinforce the context of what the Data represents
- Previous Chat Context - The history of previous natural language queries may be used for context. For example, when asking a follow up question
For accurate responses, it’s recommended to use explicit details where possible. Avoid ambiguous technical names, and instead include complete and domain-specific information, which can help the AI Agent understand the context behind natural language queries.
Responses run as an agentic multi-step process. A breakdown of the reasoning for each response is available for the User and can help verify logic, or debug failed responses.
- Filter Tool - Based on the query, the Filter Tool determines exactly which rows of Data are mathematically relevant before running the Analysis Tool
- Analysis Tool - Takes the result of the Filter Tool and compiles executable logic which runs numerical analysis on the Data
- Explain Tool - Explains the result with domain specific content as determined by the Agent Role and such that it is understandable for the User
APIs
Superstack exposes REST APIs to access both the Device raw Data, as well as the AI Agent chat feature.
Authentication
For accessing the APIs. Both the Deployment-ID
and API-Key
are required in order to make requests. They can be obtained from within the Settings Tab within Superstack.
The API Key is created using the Create Key button and can be then copied to use within the authentication header.
Remember that API Keys are secrets. Do not share it with others or expose it in any client-side code. API Keys should be securely loaded from an environment variable or key management service on the server.
The API-Key
should be specified within the X-Api-Key
header, and the Deployment-ID
should be passed to the deploymentId
field within the request body.
curl https://super.siliconwitchery.com/api/data \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: <API-Key>' \
-d '{
"deploymentId": "<Deployment-ID>"
}'
Data API
POST https://super.siliconwitchery.com/api/data
Retrieves Data from the Deployment.
Example request
curl https://super.siliconwitchery.com/api/data \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: <API-Key>' \
-d '{
"deploymentId": "00000000-0000-0000-0000-000000000000",
"devices": ["Tomatoes", "Kale", "Marjoram"],
"groups": ["greenhouse"],
"time": {
"start": "2025-06-17T13:30:00+02:00",
"end": "2025-06-17T13:45:00+02:00"
}
}'
Response
[
{
"device_name": "Marjoram",
"device_group": "greenhouse",
"timestamp": "2025-06-17T13:44:05.424264+02:00",
"data_uuid": "ad59ea27-c507-46a2-a3fa-2c185d0425d6",
"data": {
"light_lux_level": 943.86,
"temperature_celsius": 19.4,
"soil_moisture_percent": 50
}
},
{
"device_name": "Tomatoes",
"device_group": "greenhouse",
"timestamp": "2025-06-17T13:44:34.160881+02:00",
"data_uuid": "fed0532a-99e3-413e-a683-7ecda5179f54",
"data": {
"light_lux_level": 1204.56,
"temperature_celsius": 25.1,
"soil_moisture_percent": 71
}
}
]
Request parameter | Scope | Details |
---|---|---|
deploymentId | Required | string - The deployment ID as found on the Settings Tab |
devices | Optional | list of strings - A list of Device Names or IMEIs to retrieve Data for. If not provided, all Devices will be included |
groups | Optional | list of strings - A list of Device Groups to retrieve Data for. If not provided, all Groups will be included |
time.start | Optional | string - An RFC 3339 formatted time string. If not provided, this value defaults to 24 hours before the current time |
time.end | Optional | string - An RFC 3339 formatted time string. If not provided, this value defaults to the current time |
Returns | Details |
---|---|
device_name | string - The Device that generated the Data |
device_group | string - The Group that the Device belongs to |
timestamp | string - The time at which the Data was generated. Formatted as an RFC 3339 time string |
uuid | string - A unique ID for the row of Data. Can be used to delete the Data |
data | object - The Data |
DELETE https://super.siliconwitchery.com/api/data
Deletes Data from the Deployment.
This action is irreversible
Example request
curl https://super.siliconwitchery.com/api/data \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: <API-Key>' \
-d '{
"deploymentId": "00000000-0000-0000-0000-000000000000",
"ids": ["ad59ea27-c507-46a2-a3fa-2c185d0425d6", "fed0532a-99e3-413e-a683-7ecda5179f54"],
"confirm": true
}'
Request parameter | Scope | Details |
---|---|---|
deploymentId | Required | string - The deployment ID as found on the Settings Tab |
ids | Required | list of strings - A list UUIDs representing each row of data that should be deleted |
confirm | Required | boolean - Confirms that the data should be deleted. This value can be set to `false` in order to test the API without actually deleting the data |
Natural language API
POST https://super.siliconwitchery.com/api/chat
Runs a chat query with the AI Agent.
Example request
curl https://super.siliconwitchery.com/api/data \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: <API-Key>' \
-d '{
"deploymentId": "00000000-0000-0000-0000-000000000000",
"messages": [
{
"role": "user",
"content": "What is the average temperature in my greenhouse?"
}
]
}'
Response
[
{
"role": "user",
"content": "What's the average temperature in my greenhouse?",
"reasoning": {
"filter": "",
"analysis": ""
}
},
{
"role": "assistant",
"content": "The average temperature in your greenhouse over the past 6 hours is approximately 26.7°C. If you need a breakdown by specific sensor or details for a different time frame, let me know!",
"reasoning": {
"filter": "To answer your question accurately, I need to filter data to only include the latest measurements from devices located in the greenhouse within the past 6 hours. This ensures recent and relevant information from all greenhouse devices is used for your analysis.",
"analysis": "The logic is to identify all devices in the greenhouse group, extract their reported temperature values, and compute the average. If no temperature data is found for any greenhouse device, return an appropriate error message."
}
}
]
Request parameter | Scope | Details |
---|---|---|
deploymentId | Required | string - The deployment ID as found on the Settings Tab |
messages | Required | list of objects - A list of message to send to the chat agent. The response from the API may be fed back into this field if you wish the AI Agent to keep the chat history within context. Note that the tokens used relate directly to the length of this field. As the message history gets longer and longer, more tokens will be used for each query |
messages[].role | Required | string - The role of a given message. Can be either "user" or "assistant" |
messages[].content | Required | string - The content of the message query |
Returns | Details |
---|---|
role | string - The role of a given message. Can be either "user" or "assistant" |
content | string - The content of the message query |
reasoning | object - The reasoning steps that the AI Agent took while generating the response. Only relevant for the "assistant" role |
reasoning.filter | string - The reasoning behind the filter step |
reasoning.analysis | string - The reasoning behind the analysis step |
Troubleshooting / FAQ
While trying to set up my Device, the LED won’t stop blinking
This indicates that the Device is not finding any LTE network to connect to. You may need to reposition your Device in order to receive better signal strength. Additionally, make sure that your country is supported within the Device network list.
While trying to set up my Device, the LED stops blinking and doesn’t seem connected
This may indicate that either the power to the Device is insufficient, or that the Device is already connected to an active Deployment.
My Device seems connected to a Deployment but I don’t have access to it. How can I re-pair it
Access to the original Deployment is required in order to decommission a Device for re-pairing. This is to prevent unattended Devices being taken over without explicit permission of the Deployment owner.