polluSensWeb is a free and open-source browser-based tool for connecting and visualizing real air quality sensor data directly from UART devices via Web Serial API. It is designed for education, teaching labs, and rapid IoT prototyping. Try it out yourself: here
"polluSensWeb" is an independent project. Any similarity to other software names is coincidental.
- Live serial data acquisition
- Frame parsing with startByte / endByte / checksum
- Multi-command support - sequential command sequences with configurable repeats
- Dynamic charts with customizable signal style (color, thickness, tension)
- Multiple simultaneous charts
- Full CSV export (timestamp + all signals)
- Clearable log with raw serial packets and checksum result
- Configurable via external JSON file
- Supports webhooks
- Works offline after load (except webhooks)
- No external servers required
The following sensors are currently supported by polluSensWeb:
- Panasonic SN-GCJA5
- Honeywell HPMA115S0-XXX
- Air Master AM7 Plus
- Plantower PMSA003-S
- Plantower PS3003A
- Plantower PMS1003
- Plantower PMS5003
- Plantower PMS7003
- Plantower PMS6003
- Plantower PMS9103
- Plantower PMS3003
- Nova PM SDS011
- Sensirion SPS30
- SHUYI SY210
- TERA NextPM - thanks to Michael Lažan for testing! (senzorvzduchu.cz)
- SenseAir S8 004-0-0053
- SenseAir S88 Residential
- SenseAir S88 LP
- SenseAir S88 GH
- SenseAir K30
- SenseAir K33
- SenseAir eSENSE
- SenseAir S8 004-0-0017
- SenseAir K33 ICB
- Sensirion SCD30
- YYS DC01
- YYS D01
- YYS D01-P
- YYS D9
- YYS D3
- YYS D5
- YYS D7
- YYS D7B
- Winsen ZH03B
- Sensirion SEN63C (via KEL I2C to USB adapter) ...more coming soon!
Chrome ≥ 89
Edge ≥ 89
Brave ≥ 1.24
Opera ≥ 75
Other Chromium-based browsers with Web Serial API
Web Serial API is not supported in Firefox / Safari.
Sensor configuration is loaded from:
https://raw.githubusercontent.com/WeSpeakEnglish/polluSensWeb/refs/heads/main/sensors.json
Each sensor config defines:
- Serial port settings
- Frame structure:
startByteendBytelengthchecksum.evalandchecksum.compare
- Command to send on connection
send_cmd_period: send once or periodically- Parsing expressions for each signal field
- User clicks Connect → serial port opened
- Initial command is sent (if configured)
- Incoming bytes are buffered
- Frames are detected (startByte + endByte + length)
- Checksum is validated
- If valid:
- Signals parsed
updateCharts(parsedData)called- Data appended to in-memory
collectedData - Raw packet logged
- If invalid → error logged
- Sensor Selector → Choose sensor config from
sensors.json - Connect / Disconnect → Open or close serial connection
- Clear Log → Clear log area
- Save CSV → Export full collected data since connection
- Chart Name → Title of chart
- Width / Height → Chart dimensions in px
- Chart datapoints → Chart length in datapoints
- Signals Section → List of available signals:
- Checkbox to include signal
- Color picker
- Tension (line smoothness)
- Thickness (line width)
- Create Chart → Create chart with selected signals
- Multiple charts can be created dynamically.
- Each chart:
- X-axis → Time (seconds, real-time)
- Y-axis → Signal values
- Multiple signals supported
- Chart header:
- Chart title
- ❌ Cross button → removes chart
- Displays raw packets in hex.
- Shows checksum result (✅ pass / ❌ fail).
- User can clear log manually.
When user clicks Save CSV:
- The
collectedDataarray (one object per frame) is exported to CSV:- First column:
timestamp(ISO 8601) - Remaining columns: all parsed signals
- First column:
- Filename:
polluSens_data_.csv
- CSV contains all data collected since connection started.
- Load polluSensWeb → sensors loaded from
sensors.json - User selects sensor → signals list updates
- User clicks Connect → serial connection opened, command sent
- Incoming data is parsed, validated, displayed on charts, and logged
- User can:
- Create / remove charts
- Export full CSV at any time
- Clear log as needed
- User can disconnect anytime
You can upload a custom JSON configuration using the "Custom JSON Sensor Configuration" input in the interface.
- Custom entries are marked with 🆕
Top-level structure:
{
"sensors": [
{ /* sensor object */ },
...
]
}Each sensor object describes how to read and interpret data from a UART-connected sensor.
| Field | Required | Type | Description |
|---|---|---|---|
name |
yes | string | Unique sensor name (shown in dropdown) |
inherits_from |
no | string | Name of another sensor to inherit from, ex. "Plantower PMSA003-S" |
command |
no | string | Hex string to send during connection (e.g. "7E 00 03 00 FC 7E") or "none" |
start_command |
no | string | Hex string to send after connect event (e.g. "7E 00 00 02 01 03 F9 7E"). Classic mode only — ignored when commands array is present; use a repeat: 0 command instead |
stop_command |
no | string | Hex string to send on disconnect (e.g. "7E 00 01 00 FE 7E"). Works in both classic and multi-command modes — always sent on disconnect regardless of whether command or commands is used |
send_cmd_period |
no | number | If > 0, send command every N seconds, if = 0 - once |
commands |
no | array | Array of command objects for multi-command sequences (see below) |
port |
yes | object | fields: see below |
frame |
yes | object | fields: see below |
data |
yes | object | fields: see below |
| Field | Required | Type | Description |
|---|---|---|---|
baudRate |
yes | integer | connection speed, ex. 9600, 19200, 115200 |
dataBits |
yes | integer | bits in byte, typically 8 |
stopBits |
yes | integer | stop bits quantity, typically 1 |
parity |
yes | integer | parity, ex. "none", "even", "odd" |
| Field | Required | Type | Description |
|---|---|---|---|
startByte |
yes | string | start byte or bytes, ex. [66, 77], ["0x42", "0x4D"], 170, "0xAA", "none" |
endByte |
yes | string | multi-byte terminator; similar to startByte |
length |
yes | string | frame length including start and stop bytes, in bytestuffing case / after unstuffing |
stuffing |
no | object | contain stuffing pairs: what to find and what to place instead, ex. ["7D 5E", "0x7E"], ["7D 5D", "0x7D"] |
| Field | Required | Type | Description |
|---|---|---|---|
eval |
yes | string | valid JS expression, assuming data[i] is i-th byte in received buffer, ex. "data.slice(1, 30).reduce((a, b) => a ^ b, 0)" |
compare |
yes | string | valid JS expression, assuming data[i] is i-th byte in received buffer, ex. data[30] |
Defines how to extract and interpret sensor readings from a binary data frame Example:
"Some parameter": {
"value": "((data[1] << 8) + data[2])>>>0",
"unit": "μg/m³"
},...| Field | Required | Type | Description |
|---|---|---|---|
value |
yes | string | valid JS expression, assuming data[i] is i-th byte in received buffer, ex. "((data[1] << 8) + data[2])>>>0" |
unit |
yes | string | units like, ex. "μg/m³" |
| Field | Required | Type | Description |
|---|---|---|---|
command |
yes | string | Hex command string (e.g., "1B 41 6B 57 00 52 03 53") |
id |
no | number | 0-based position index used by child sensors to override this command via inheritance (see Per-command override) |
repeat |
no | number | 0 = init (run once), ≥1 = repeat N times per measurement cycle |
postDelay_ms |
no | number | Delay in milliseconds after command before next operation |
frame |
no | object | Frame specification for response parsing (same as main frame) |
checksum |
no | object | Checksum validation for response (same as main checksum) |
data |
no | object | Data extraction rules for response (same as main data) |
{
"sensors": [
{
"name": "Honeywell HPMA115S0-XXX",
"command": "none",
"port": {
"baudRate": 9600,
"dataBits": 8,
"stopBits": 1,
"parity": "none"
},
"frame": {
"length": 32,
"startByte": [66, 77],
"endByte": "none"
},
"data": {
"PM2.5": {
"value": "(data[6] << 8) + data[7]",
"unit": "μg/m³"
},
"PM10": {
"value": "(data[8] << 8) + data[9]",
"unit": "μg/m³"
}
},
"checksum": {
"eval": "data.slice(0, 30).reduce((a, b) => (a + b) & 0xFFFF, 0)",
"compare": "(data[30] << 8) + data[31]"
}
}
]
}You can reuse and override parts of existing sensors:
{
"name": "Your New Sensor PM2.5",
"inherits_from": "Plantower PMSA003",
"data": {
"Humidity": {
"value": "(data[14] << 8) + data[5]",
"unit": "%"
}
}
}This example keeps all settings from Plantower PMSA003 but adds a humidity value.
polluSensWeb now supports complex sensor initialization and measurement sequences using the commands array. This is essential for sensors that require multiple commands to configure, calibrate, or read data.
- Sensors requiring initialization sequences (e.g., SCD30, SEN63C)
- Sensors with multiple measurement modes
- Sensors needing configuration before reading
- Complex sensors with calibration routines
Each command in the commands array can have:
| Field | Type | Required | Description |
|---|---|---|---|
command |
string | yes | Hex command to send (e.g., "1B 41 6B 57 00 52 03 53") |
frame |
object | no | Frame specification for response parsing |
checksum |
object | no | Checksum validation for response frame |
data |
object | no | Data extraction rules for response frame |
repeat |
number | no | 0 = init (run once), ≥1 = repeat N times per cycle |
postDelay_ms |
number | no | Delay in milliseconds after command execution |
- Init Phase (
repeat: 0): Commands run once before measurement loop - Measurement Cycle (
repeat: ≥1): Commands repeated continuously
Init Commands (repeat: 0):
- Run once when connection starts
- Used for sensor configuration, calibration, or setup
- Can include their own frame parsing
Cycle Commands (repeat: ≥1):
- Run continuously in a loop
- Each command can have its own repeat count and delay
- The entire cycle repeats while connection is active
Frame Handling:
- Each command can define its own frame structure
- Responses are parsed independently
- Data from any command can update charts
start_command / stop_command in multi-command mode:
start_commandis not sent whencommandsarray is present — use arepeat: 0entry incommandsfor one-time init insteadstop_commandis always sent on disconnect, regardless of whether the sensor uses classic or multi-command mode
Multi-command example:
{
"commands": [
{
"command": "7E 00 03 00 FC 7E",
"repeat": 1,
"postDelay_ms": 50,
"frame": { ... },
"data": { ... },
"checksum": { ... }
},
{
"command": "67 00 05 00 FE 7E",
"repeat": 1,
"postDelay_ms": 50,
"frame": { ... },
"data": { ... },
"checksum": { ... }
}
]
}When the parent sensor uses a commands array, a child sensor can override individual commands without repeating the entire array. Use the id field on a child command to specify which position (0-based) in the parent's commands array to merge into.
Rules:
idis a 0-based integer index into the parent'scommandsarray- Only the fields listed in the child command are overridden; all other fields are inherited from the parent command
- A child command with no
id, or with anidthat is out of range, is appended as a new command after the inherited ones - Parent commands need no
idfield — only child overrides use it
Example: parent defines two commands; child only changes postDelay_ms on the second one (id: 1):
{
"name": "MySensor Fast",
"inherits_from": "MySensor",
"commands": [
{ "id": 1, "postDelay_ms": 100 }
]
}All other fields of command at position 1 (command, frame, data, checksum, repeat, etc.) are inherited unchanged from MySensor.
- All
value,eval, andcomparefields are evaluated using JavaScripteval(). - You can use decimal values (
66) or hex strings ("0x42") — no raw hex like0x42. - If your JSON fails to load, check the browser log or validate at https://jsonlint.com.
- For multi-command sensors, ensure proper
postDelay_msvalues to allow sensor processing time. - Use
repeat: 0for one-time initialization commands. - Use
repeat: ≥1for continuous measurement commands.
polluSensWeb can send parsed sensor data to any HTTP webhook.
Webhooks now support placeholders in both body and headers.
- Check Enable Webhook Sending in the Webhook card.
- Enter your Webhook URL.
- Select HTTP Method (
GET,POST,PUT). - Set interval in seconds (0 = send on every packet).
Headers can include placeholders, just like the body.
Supported placeholders:
{{ts}}– current timestamp (ISO format){{field:<name>}}– value of a specific sensor field
| Key | Value | Result Example |
|---|---|---|
| X-PM25 | {{field:PM2_5}} | X-PM25: 12.3 |
| X-TIME | {{ts}} | X-TIME: 2025-12-09T12:34:56Z |
| X-CUSTOM | {{field:PM10}} | X-CUSTOM: 20.1 |
Add headers using the + Add header button.
Body templates are JSON-based, supporting placeholders:
{{ts}}– timestamp{{field:<signal name>}}– sensor value{{#fields}} … {{/fields}}– loop through all sensor fields
{
"software_version": "polluSensWeb 1.0",
"timestamp": "{{ts}}",
"sensordatavalues": [
{{#fields}}
{ "value_type": "{{key}}", "value": "{{value}}" }
{{/fields}}
]
}For a packet:
PM1: 1.5, PM2.5: 12.3, PM10: 20.1
The processed body will be:
{
"software_version": "polluSensWeb 1.0",
"timestamp": "2025-12-09T12:34:56.789Z",
"sensordatavalues": [
{ "value_type": "PM1", "value": 1.5 },
{ "value_type": "PM2.5", "value": 12.3 },
{ "value_type": "PM10", "value": 20.1 }
]
}{
"software_version": "polluSensWeb 1.0",
"timestamp": "{{ts}}",
"sensordatavalues": [
{"PM2.5": "{{field:PM2.5 x0.4 calibrated}}"}
]
}Here PM2.5 x0.4 calibrated is the signal (signal names you see in the form where you select them for chart at the top of UI), before [units], for this example it was: PM2.5 x0.4 calibrated [μg/m³]
- Click Test Send Webhook Now to test.
- Webhooks respect your interval setting. If interval = 0, every parsed packet triggers a request.
Headers
X-PM25: {{field:PM2.5}}
X-Time: {{ts}}
Content-Type: application/json
Body
{
"pm25": {{field:PM2.5}},
"pm10": {{field:PM10}},
"time": "{{ts}}"
}Processed Request Example
POST https://webhook.site/xxxxxx
Headers:
X-PM25: 12.3
X-Time: 2025-12-09T12:34:56Z
Content-Type: application/json
Body:
{
"pm25": 12.3,
"pm10": 20.1,
"time": "2025-12-09T12:34:56Z"
}
- Headers are processed through the same template engine as the body.
- Placeholders not found in the data will be replaced with
"null"for fields. - Rate-limit protection is active by default.
Ready to use with any HTTP endpoint that accepts JSON or custom headers.
To run is selfhosted: replace CORS proxy and sensor list addresses in pollusensweb.js (at the very top) with your actual addresses:
const DEFAULT_SENSOR_LIST = "ADDRESS_TO_sensors.json";
const PROXY_URL = "ADDRESS_TO_proxy.php";Extact release archive or open git clone, open index.html in browser and load JSON file sensors.js via Custom JSON Sensor Configuration in UI.
- Default sensors:
sensors.json - Project homepage: pollutants.eu/sensor
- Hackaday project: Connect any UART sensor in your browser
