Atmosis — AI Environmental Intelligence & Health Advisory

by Next Builder DIY in Circuits > Sensors

155 Views, 2 Favorites, 0 Comments

Atmosis — AI Environmental Intelligence & Health Advisory

Atmosis 02.png
I Built a Device That Understands the Air You Breathe — Atmosis

We usually think of an air-quality monitor as a device that measures the environment and displays a few numbers. But those numbers don't always tell us what is actually happening or what we should do about it.

Atmosis was built to bridge that gap.

Atmosis is an intelligent environmental monitoring and advisory system built around the Arduino UNO Q. It combines real-time environmental sensing, edge machine learning, Linux-based processing, generative AI, and remote notifications to turn raw environmental data into useful information.

The system continuously monitors parameters such as IAQ, TVOC, formaldehyde, carbon monoxide, temperature, humidity, PM2.5, atmospheric pressure, and altitude. Rather than treating each measurement independently, Atmosis looks at how these values change together to identify meaningful environmental conditions across different environments.

This is where Edge Impulse is used. A machine-learning model trained with data collected from the same sensors learns the patterns between multiple environmental parameters and classifies conditions such as clean air, traffic pollution, construction dust, indoor pollution, poor ventilation, biomass burning, and unusual events. The model runs locally, allowing the core environmental analysis to happen directly on the device.

The Arduino UNO Q is what brings the different parts of the system together. Its STM32U585 microcontroller handles the real-time sensor processing, air-quality calculations, status indicators, and safety-related functions, while the Qualcomm QRB2210 Linux processor runs the higher-level software, including Edge Impulse inference, the dashboard, Telegram communication, and the AI advisor.

Once Atmosis identifies an environmental condition, Google Gemini can be used to interpret the structured data and generate a clear, natural-language recommendation. Gemini is not responsible for the sensor measurements or safety decisions; it acts as an additional intelligence layer that makes the detected information easier to understand.

Because Atmosis can also work with personal sensitivity information, privacy is considered as part of the architecture. Sensitive health information can remain on the UNO Q, while only the necessary environmental data and a privacy-safe sensitivity context are provided to the AI service. The resulting advice is then returned to the device and can be shown on the local dashboard or delivered through Telegram.

The system is also designed so that its core monitoring and safety functions do not depend on the Internet. If the cloud services or Linux application become unavailable, the microcontroller can continue monitoring the environment and handling the essential hardware-level responses.

At its core, Atmosis follows a simple process: Sense → Understand → Interpret → Respond

The sensors provide the data, the UNO Q processes it, Edge Impulse recognizes the environmental pattern, and the AI layer turns that information into something a person can actually understand and use.

Atmosis is not just another environmental monitor. It is an attempt to make environmental data more intelligent, more useful, and more adaptable — across indoor spaces, outdoor conditions, pollution events, and changing environmental situations — while keeping the important parts of the system local and dependable.

Supplies

1.png
2.png
3.png
4.png
5.png
6.png
Components
  1. Arduin UNO Q (Amazon)
  2. DPS310 Sensor (Amazon)
  3. Waveshare X6 Sensor (Waveshare)
  4. Waveshare Dust Sensor (Waveshare)
  5. Waveshare RGB Strip (Waveshare)
Tools Required
  1. Soldering Iron (Amazon)
  2. Solder Wire (Amazon)
  3. Pliers (Amazon)
  4. 3D Printer (Amazon)

How Atmosis Works

31.png

Atmosis follows a layered architecture where each part of the system has a specific role. Raw sensor measurements are progressively transformed into environmental status, pattern recognition, and actionable information.

Three sensors continuously provide nine environmental parameters: IAQ, TVOC, HCHO, CO, temperature, humidity, PM2.5, pressure, and altitude. The STM32U585 handles these measurements in a non-blocking firmware loop, calculates the air score, controls the WS2812B status light, and responds to critical PM2.5 and CO conditions directly on the board.

The processed data is then sent once per second through the Arduino Router Bridge as a compact data frame containing the latest measurements, status information, diagnostics, and timing data. The connection is intentionally one-way, keeping the real-time layer independent from the Linux application.

On the Linux side, the application validates the incoming data, maintains the measurement history, and manages the event logic. Edge Impulse works on a rolling window of the nine sensor axes to recognise environmental patterns such as Clean air, Indoor pollution, and Poor ventilation.

When a condition requires further interpretation, the structured environmental data is passed to Gemini. Rather than handling raw sensor measurements directly, Gemini receives the processed state and turns it into a clear, contextual recommendation.

The final information is presented through the local dashboard and Telegram, providing live measurements, trends, detected patterns, system status, and AI-generated guidance.

This architecture keeps the time-critical processing local while using the Linux side and external services for higher-level analysis, allowing Atmosis to continuously sense, classify, interpret, and respond without making its core operation dependent on the cloud.

Design in Fusion 360

31.png
32.png
33.png
34.png

I designed the complete Atmosis enclosure from scratch in Autodesk Fusion 360. I started by creating the basic enclosure structure and then designed the internal mounting points, openings, and component placements around the actual hardware.

The enclosure was designed with a clean and modern look, while keeping the sensor areas open with dedicated cutouts for proper airflow. I also integrated the mounting features and snap-fit mechanism directly into the design.

3D Printing

7.png
8.png
9.png

I 3D printed the enclosure using gray PLA filament as the main material. To give the enclosure a cleaner and more distinctive look, I used orange PLA for the text and logo details.

The combination of gray and orange gives the enclosure a simple, modern finish while keeping the printed details clearly visible.

Dust Sensor Assembly

10.png
11.png
12.png

The dust sensor is used to detect airborne particulate matter and provide Atmosis with real-time information about dust and particle concentration in the surrounding air.

Connect the dust sensor to its cable and position it in the dedicated mount inside the 3D-printed housing then align it with the mounting holes and secure it with the provided screw.

X6 Sensor Assembly

13.png
14.png
15.png

The Waveshare Environment X6 is a multi-gas environmental sensor that uses solid-state polymer electrochemical technology to detect IAQ, TVOC, HCHO, and CO. It communicates with the Arduino UNO Q over UART.

Connect the X6 sensor to its cable and place it in its designated position inside the 3D-printed housing. Align the mounting hole with the corresponding mounting point and secure the sensor using the provided screw. Keep the sensor area unobstructed so the surrounding air can reach the sensing element properly.

DPS310 Sensor Assembly

16.png
17.png
18.png

The DPS310 is a digital barometric pressure sensor used to measure atmospheric pressure and derive altitude-related data.

Connect the DPS310 sensor to its cable and place it in the designated opening on the 3D-printed top cover. Align the sensor with the mounting position and secure it in place using the provided screw. Keep the sensor opening unobstructed to allow accurate pressure measurements.

Connection

Connection.jpg
19.png
20.png

You can follow the wiring diagram above to connect each sensor and the RGB LED to the Arduino UNO Q according to the connections listed in this section.

Waveshare X6 Sensor

  1. VCC → 5V
  2. GND → GND
  3. TXD → D0
  4. RXD → D1

Dust Sensor

  1. VCC → 5V
  2. GND → GND
  3. ILED → D4
  4. AOUT → A0

DPS310 Sensor

  1. VCC → 3.3V
  2. GND → GND
  3. SDA → SDA
  4. SCL → SCL

RGB LED

  1. UNO Q D5 → DIN
  2. VCC → 5V
  3. GND → GND

For a neat and clean assembly, I first connected all the JST connector wires to the Arduino UNO Q according to the wiring diagram.

Arduino UNO Q Assembly

21.png
22.png
23.png

Once all the connections were in place, I connected the other ends of the cables to their respective sensors and arranged the wiring inside the enclosure.

Once all the cables are connected, carefully place the Arduino UNO Q in its designated position inside the enclosure. Arrange the connected wires neatly around the board so they do not interfere with the other components, then secure the board in place using the provided mounting screws.

RGB LED Strip Assembly

24.png
25.png
26.png

For the visual feedback, I used the Waveshare ultra-slim RGB COB LED strip. Its compact form factor makes it ideal for fitting inside the enclosure while still providing bright, diffused RGB illumination. The strip uses WS2812B addressable LEDs, allowing Atmosis to control the color and use it as a simple real-time status indicator.

Cut the required length of the LED strip and peel off the adhesive backing then place it on the available flat surface inside the enclosure.

Final Assembly

27.png
28.png
29.png

With all the components installed and the wiring neatly arranged, place the 3D-printed top cover onto the main enclosure. Carefully align the snap-fit tabs with the corresponding slots and press the cover down evenly until all the clips lock into place.

Install Arduino UNO Q Board

Before you begin, make sure you have the Arduino IDE installed on your computer. If you don’t have it yet, download the latest version from the official Arduino website and complete the installation process for your operating system (Windows, macOS, or Linux). Once installed, open the Arduino IDE to continue.

Now, to add support for the Arduino UNO Q board, go to the top menu and open Tools → Board → Boards Manager. In the search bar, type Arduino UNO Q. From the results, select the official Arduino UNO Q board package and click Install. Wait for the installation to finish; this may take a few moments depending on your internet connection.

Upload Data Collection Code

1A.png

Begin by uploading the Atmosis data collection firmware to the Arduino UNO Q. This firmware initializes the connected environmental sensors, reads their measurements, and outputs the data in the format required for the Edge Impulse data-acquisition workflow.

  1. Download the complete Atmosis code from the GitHub repository and extract the ZIP.
  2. Open atmosis.ino in Arduino IDE.
  3. Connect the Arduino UNO Q via USB-C, then select Tools → Board → Arduino UNO Q and the correct Port.
  4. Click Upload and wait for the compilation and upload to complete successfully.
Important: Do not open Arduino IDE's Serial Monitor before starting the Edge Impulse Data Forwarder later. The serial port must remain available to the Data Forwarder so it can receive the Atmosis sensor stream.

The firmware outputs the environmental measurements as a continuous, comma-separated numerical stream, which is the format used in the following Edge Impulse data-acquisition step.

Edge Impulse Account and Project

01.png
02.png
03.png
04.png

Edge Impulse is an edge-AI development platform that makes it possible to build machine-learning models for embedded and edge devices. It provides the complete workflow for collecting sensor data, labeling datasets, designing an ML pipeline, training and evaluating models, and deploying the final model to hardware.

For Atmosis, Edge Impulse is used to learn environmental patterns from multiple sensor readings rather than relying only on fixed threshold values. This allows the system to recognize different environmental conditions from the combined sensor data.

  1. Open Edge Impulse and click Sign Up. Register using your email or Google account. If you registered with email, verify your account and open Edge Impulse Studio.
  2. Click Create new project and enter the project name Atmosis-Environmental-Classifier.
  3. Choose the Developer plan when prompted and create the project. Once created, the dashboard will be ready for the next stages
  4. Open the project's Board section and select Arduino UNO Q as the target device.

Install Node.js and Set Up the CLI

05.png
06.png

Node.js allows your computer to run the command-line tools provided by Edge Impulse. You do not need to write any Node.js code here — we only need it as part of the setup for the Edge Impulse CLI. This is a one-time installation.

Open nodejs.org in your browser → download the LTS (Long Term Support) version → open the downloaded installer → follow the installation steps using the default options → complete the installation.

Once Node.js is installed, open a Terminal on your computer. On Windows, open Command Prompt or PowerShell from the Start menu. On macOS, open Terminal using Spotlight. On Linux, open the Terminal application.

To verify the installation, type the following command and press Enter:

node --version

You should see a Node.js version number such as v20.x.x or newer. Then check that npm, the package manager included with Node.js, is also available:

npm --version

If both commands return version numbers, Node.js and npm are installed correctly.

Install the Edge Impulse CLI

07.png
08.png

Now that Node.js is installed, we can install the Edge Impulse CLI. This command-line tool allows the computer to communicate with Edge Impulse and forward sensor data from the Atmosis hardware to your project.

In the same Terminal → run the following command:

npm install -g edge-impulse-cli@latest

The -g option installs the CLI globally, so the Edge Impulse commands can be used from any folder on your computer.

The installation may take a few minutes, and you will see a lot of text in the terminal while the required packages are being installed. This is normal — simply wait for the process to finish.

If you are using macOS or Linux and the installation returns a permission denied or EACCES error, run:

sudo npm install -g edge-impulse-cli@latest

Enter your computer password when prompted and allow the installation to complete.

Finally, verify that the CLI was installed correctly by running:

edge-impulse-data-forwarder --version

If a version number is displayed, the Edge Impulse CLI has been installed successfully.

Connect to Edge Impulse With Data Forwarder

09.png

The Edge Impulse Data Forwarder streams the sensor readings from the Arduino UNO Q directly to your Edge Impulse project over the serial connection. Keep the UNO Q connected and make sure the Atmosis data-collection firmware is already uploaded.

  1. Connect the Arduino UNO Q to your computer via USB and make sure the atmosis_data_collect.ino firmware is running.
  2. Open Command Prompt and run:
edge-impulse-data-forwarder
  1. The Data Forwarder will automatically detect the available serial port and establish a connection with Edge Impulse.
  2. When prompted, enter the axis names in exactly the same order as the values printed by Atmosis:
iaq,tvoc,hcho,co,temp,rh_pct,dust_ugm3,pressure_hpa,altitude_m

When prompted, enter AtmosisQ as the device name. After that a successful connection should show the detected sensor axes and a message confirming that AtmosisQ is connected to the Atmosis-Environmental-Classifier project.

Leave the terminal window open throughout the data-collection process. Closing it will stop the data stream to Edge Impulse.

Note: The Data Forwarder may automatically detect and display the current data frequency. The frequency shown should correspond to the actual sampling rate of the Atmosis firmware.


Collect and Label the Environmental Data

35.png
10.png
11.png

With AtmosisQ connected to Edge Impulse, the next step is to build the training dataset. The model learns from these real sensor measurements, so each recording must represent the environmental condition assigned to its label.

In Data acquisition, select AtmosisQ as the device. Select the 9-axis sensor, keep the sample length at 10000 ms (10 seconds), and leave the acquisition frequency at 3 Hz, as shown by Edge Impulse. Enter the appropriate label and click Start sampling to record the data.

Important: A 10-second acquisition may appear as approximately 9 seconds in the dataset after recording. This is normal.

Environmental Labels

Use the following labels consistently throughout the dataset:

  1. Clean_air: Normal, relatively clean indoor conditions. Collect plenty of samples from different times and locations to establish the baseline environment.
  2. Traffic_pollution: Measurements taken near a busy road with passing traffic. Keep the recording brief and avoid direct exposure to vehicle exhaust.
  3. Construction_dust: Measurements from a genuine construction or dusty environment when safely available. Do not deliberately create hazardous dust for data collection.
  4. Indoor_pollution: Temporary indoor pollution such as cooking, incense, or other controlled household sources. Keep exposure brief and safe.
  5. Poor_ventilation: Environments with limited airflow, such as a closed room where air quality gradually changes.
  6. Biomass_burning: Capture this condition only when it occurs naturally and safely. Do not deliberately create a fire or burning source for the dataset.
  7. Unusual_event: Genuine environmental conditions or anomalies that do not clearly belong to the other classes. This can naturally remain the smallest class.

For each class, create multiple independent recordings rather than repeatedly recording the exact same condition. Variation in location, time, and environmental conditions helps the model learn the underlying patterns instead of memorizing one specific recording.

Most importantly, always use the label that accurately describes the environment being measured. Do not assign a pollution label to clean-air data simply to increase the number of samples. This keeps the dataset reliable and makes the trained Atmosis classifier much more meaningful.

Create the Impulse

12.png
13.png
14.png

After collecting the environmental dataset, the next step is to define how Edge Impulse will process the sensor data and train the machine-learning model. This configuration is called an Impulse.

For Atmosis, the Impulse uses all nine environmental sensor axes as input, Raw Data as the processing block, and Classification as the learning block.

Configure the Impulse

  1. Open Impulse design → Create impulse from the left sidebar.
  2. In Time series data, keep all 9 input axes enabled: iaq, tvoc_ppm, hcho_ppm, co_ppm, temp_c, rh_pct, dust_ugm3, pressure_hpa, and altitude_m.
  3. Set the Window size to approximately 3 seconds (3,000 ms), Window increase (stride) to 1,000 ms, and Frequency to 5 Hz. Keep Zero-pad data enabled and Train on data subset at 100%.

Add the Processing and Learning Blocks

Under Add a processing block, select Raw Data and click Add. Keep all nine sensor axes selected. Next, under Add a learning block, select Classification and click Add.

The final structure should be: Time series data → Raw Data → Classification & Finally, click Save Impulse.

Generate Features From Raw Data

15.png
16.png
17.png

After saving the Impulse, open Impulse design → Raw data from the left sidebar. This section prepares the collected sensor data for the classifier.

Under Parameters, leave Scale axes at its default value of 1 and click Save parameters. Then open the Generate features tab. Keep the following settings unchanged:

  1. Calculate feature importance: Leave unchecked
  2. Normalize features: Select Don't normalize data

Click Generate features to process the complete training dataset.

Edge Impulse will resample the collected files, divide them into the configured 3-second windows, and pass the nine sensor axes directly through the Raw Data processing block. In the current Atmosis dataset, this generates 141 training windows across the three classes: Clean_air, Indoor_pollution, and Poor_ventilation.

Wait until the Feature generation output shows Job completed (success).

The Feature explorer should then display separate clusters for the three environmental classes. Clear separation between the clusters indicates that the collected sensor patterns contain useful information for the classifier.

At this point, the raw sensor data has been successfully converted into the training data required for the Classification model.

Train the Classification Model

18.png
19.png

With the features generated, the final stage of the machine-learning pipeline is to train the Classification model. This is where Edge Impulse learns the patterns that distinguish the environmental conditions in the dataset.

Open Impulse design → Classifier from the left sidebar. Under Training settings, keep the default values shown by Edge Impulse:

  1. Number of training cycles: 30
  2. Use learned optimizer: Disabled
  3. Learning rate: 0.0005
  4. Training processor: CPU

Leave the Neural network architecture unchanged. In this configuration, Edge Impulse uses 135 input features, followed by dense layers of 20 and 10 neurons, and an output layer containing the 3 classes in the current dataset.

Click Save & train to start training. Edge Impulse will train the neural network using the generated sensor windows and automatically evaluate its performance on the validation data. Wait until the Training output shows Job completed (success).

The trained model will then display its performance, including the accuracy, loss, confusion matrix, and F1 scores. In the current Atmosis training run, the model achieved 93.1% validation accuracy, with the confusion matrix showing how well the model distinguishes Clean_air, Indoor_pollution, and Poor_ventilation.

Note: This result is based on the current dataset and should be treated as an initial model performance. Adding more representative samples for each environmental condition can improve the model's ability to generalize to real-world conditions.


Deploy the Trained Model

20.png
21.png

Once the model has been trained and tested, the final step is to package it for use on the Arduino UNO Q. Edge Impulse converts the trained model into a lightweight embedded library that can run locally on the device without requiring a cloud connection.

  1. Open Deployment from the left sidebar. Under Deployment target, select C++ library. Keep EON™ Compiler selected as the inference engine.
  2. Under Model optimizations and performance, keep Quantized (int8) selected. This produces a compact model optimized for embedded deployment while retaining the trained classifier.
  3. Confirm that the target is set to Arduino UNO Q (Qualcomm QRB2210), then click Build.
  4. Edge Impulse will compile the model and generate the deployment package. Wait until the job shows Job completed (success) and the Built C++ library confirmation appears.
  5. The generated C++ library can now be integrated into the Atmosis firmware, allowing the Arduino UNO Q to perform environmental classification locally from the sensor data.

At this point, the Atmosis machine-learning model has been successfully trained and compiled for embedded deployment. The next stage is to integrate this generated library into the Atmosis application and run the classifier directly on the Arduino UNO Q.

Get Your Gemini API Key

22.png

To connect Atmosis with Gemini, you first need a Gemini API key from Google AI Studio.

Open Google AI Studio and sign in with your Google account. Once you're signed in, open the API Keys section and select Create API key. Choose the appropriate Google Cloud project if prompted, then create the key.

Copy the generated API key and keep it somewhere secure. You’ll need it later when configuring the Atmosis software.

Create a Telegram Bot

23.png

To receive real-time alerts from Atmosis, we first need to create a Telegram bot. Open Telegram and search for @BotFather, the official bot used to create and manage Telegram bots.

  1. Start a conversation with BotFather and send: /newbot.
  2. Enter the bot name. For this project, I used: Atmosis
  3. BotFather will then ask for a unique username that must end with bot. I used: AtmosisBot

Once the bot is created, BotFather will provide an HTTP API token. Copy this token and store it securely, as Atmosis will use it later to send notifications through the Telegram Bot API.

Get the Telegram Chat ID

24.png

Now we need the Chat ID that Atmosis will use to send notifications to your Telegram account. First, open the Telegram bot you created and send it a simple message, such as: Hi

Then open the following URL in your browser, replacing YOUR_BOT_TOKEN with the token you received from BotFather:

https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates

The API will return a JSON response containing the messages received by your bot. Look for the chat section. Inside it, you will find:

"chat": {
"id": 6599107208,
...
}

The number shown next to id is your Telegram Chat ID. Copy this value and save it along with your bot token. Atmosis will use both later to send alerts and reports to your Telegram account.

Install Arduino App Lab

25.png

Arduino App Lab is the unified development environment for the Arduino UNO Q. It brings the board’s STM32 microcontroller and Linux processor into a single workflow, allowing Arduino sketches, Python applications, and other Linux components to work together as one application.

For Atmosis, App Lab is used to build, deploy, and manage the complete UNO Q application.

Open the official Arduino software page and find Arduino App Lab. Select your operating system and download the latest available version.

After the download finishes, install it normally: Windows: Run the .exe installer → follow the setup prompts → launch Arduino App Lab.

Once App Lab opens, connect your Arduino UNO Q using a USB-C data cable. App Lab should automatically detect the board and guide you through the initial setup and any available software updates.

Build Configuration

Arduino App Lab builds both halves of the project from two small manifests. app.yaml names the app and exposes port 7000 for the dashboard. sketch.yaml selects the UNO Q board profile and pins every library to an exact version, so every build of Atmosis compiles against the same code.

sketch/sketch.yaml

profiles:
default:
fqbn: arduino:zephyr:unoq
platforms:
- platform: arduino:zephyr
libraries:
- Adafruit DPS310 (1.1.6)
- Adafruit BusIO (1.17.3)
- Adafruit Unified Sensor (1.1.15)
- Adafruit NeoPixel (1.15.5)
default_profile: default

All firmware constants live in config.h: pins, timings, sensor ranges, alarm thresholds and the frame protocol version. Compile-time checks stop an invalid configuration from ever reaching the board.

sketch/config.h (excerpt)

constexpr uint32_t FRAME_INTERVAL_MS = 1000;
constexpr uint32_t LOOP_YIELD_MS = 4;
constexpr uint8_tFRAME_PROTOCOL = 5;
constexpr floatDUST_ALERT_UGM3 = 150.0f;
constexpr floatDUST_ALERT_CLEAR_UGM3 = 130.0f;
constexpr floatCO_ALERT_PPM = 9.0f;
constexpr floatCO_ALERT_CLEAR_PPM = 8.0f;
static_assert(DUST_ALERT_CLEAR_UGM3 < DUST_ALERT_UGM3, "Dust hysteresis must be positive");
static_assert(CO_ALERT_CLEAR_PPM < CO_ALERT_PPM, "CO hysteresis must be positive");
static_assert(DUST_SAMPLE_DELAY_US == 280,
"Sharp/Waveshare sample point is 280 us after the LED turns on");

Firmware: the Real-time Layer

The main loop

The firmware is fully non-blocking. Each pass of loop() gives every driver a short turn, then decides whether it is time to send a frame. No driver waits for a sensor, so a slow or disconnected device can never freeze the board.

sketch/sketch.ino — loop()

void loop() {
const uint32_t now = millis();
trackLoopTime(now);
statusLed.update(now);
x6Sensor.poll(now);
lastX6 = x6Sensor.reading(now);
dustSensor.poll(now);
pressureSensor.update(now);
DustReading dust;
if (dustSensor.consume(dust)) {
lastDust = dust;
if (dust.valid) {
if (dust.density_ugm3 >= DUST_ALERT_UGM3) dustAlarm = true;
else if (dust.density_ugm3 <= DUST_ALERT_CLEAR_UGM3) dustAlarm = false;
}
}
if ((int32_t)(now - nextFrameMs) >= 0) {
nextFrameMs += FRAME_INTERVAL_MS;
if ((int32_t)(now - nextFrameMs) >= 0) nextFrameMs = now + FRAME_INTERVAL_MS;
updateAlarmsAndLight();
pushFrame(now);
}
if ((int32_t)(now - nextInfoMs) >= 0) {
nextInfoMs = now + INFO_INTERVAL_MS;
pushInfo();
}
delay(LOOP_YIELD_MS);
}

Three details matter here:

  1. Wrap-safe timing. Deadlines are compared as (int32_t)(now - deadline) >= 0, which stays correct when millis() wraps around after about 49 days.
  2. Drift-free schedule. nextFrameMs advances by exactly one interval, so frames stay on a steady one-second grid. If the loop ever falls behind, the schedule resets instead of sending a burst of frames.
  3. A short yield. delay(4) hands time back to Zephyr on every pass, which keeps the Bridge serviced and the board responsive.

Environment X6 driver

The Waveshare Environment X6 answers a two-byte query (0x70 plus checksum) with a 22-byte frame. The driver is a small two-state machine. It sends a query once a second, then collects bytes as they arrive on later loop passes, with a 250 ms timeout. A frame is accepted only if the byte sum is zero and every value is inside the sensor's physical range.

sketch/sensors.h — EnvironmentX6::decode()

void decode() {
uint32_t sum = 0;
for (uint8_t i = 0; i < X6_RESPONSE_LEN; ++i) sum += buffer_[i];
if ((sum & 0xFF) != 0) { framesBad_++; status_ = X6_CHECKSUM; return; }
X6Reading r;
r.iaq = bigEndianFloat(&buffer_[1]);
r.tvoc_ppm = bigEndianFloat(&buffer_[5]);
r.hcho_ppm = bigEndianFloat(&buffer_[9]);
r.co_ppm = bigEndianFloat(&buffer_[13]);
r.temperature_c = (float)(int16_t)(((uint16_t)buffer_[17] << 8) | buffer_[18]) / 100.0f;
r.humidity_pct = (float)(((uint16_t)buffer_[19] << 8) | buffer_[20]) / 100.0f;
if (!plausible(r)) { framesBad_++; status_ = X6_OUT_OF_RANGE; return; }
r.valid = true;
last_ = r;
lastGoodMs_ = millis();
framesOk_++;
status_ = X6_OK;
}

bigEndianFloat() rebuilds each 32-bit float with memcpy, which is the safe, portable way to reinterpret bytes in C++. If no good frame arrives for 3.5 seconds, reading() marks the data as stale, so old values are never reported as live.

Dust sensor

The Sharp GP2Y1010 sensor must be read at a precise moment: 280 µs after its infrared LED switches on. The firmware follows Waveshare's documented method exactly. It takes ten pulses per batch, one every 10 ms, and flags any pulse whose timing overran.

sketch/sensors.h — DustSensor::pulse() and conversion

void pulse() {
digitalWrite(PIN_DUST_ILED, HIGH);
const uint32_t start = micros();
waitUntil(start, DUST_SAMPLE_DELAY_US);
const int raw = analogRead(PIN_DUST_AOUT);
digitalWrite(PIN_DUST_ILED, LOW);
lastPulseUs_ = start;
if ((uint32_t)(micros() - start) > DUST_PULSE_MAX_US) timingOk_ = false;
if (raw < 0) { failed_++; return; }
if (raw >= (int)ADC_MAX_COUNT - 2) saturated_++;
samples_[count_++] = raw;
}
inline float dustDensityFromSensorMv(float sensorMv) {
if (sensorMv <= DUST_ZERO_MV) return 0.0f;
const float d = (sensorMv - DUST_ZERO_MV) * DUST_UGM3_PER_MV;
return d > DUST_MAX_UGM3 ? DUST_MAX_UGM3 : d;
}

Each batch then goes through four steps:

  1. The ten samples are sorted, and the highest and lowest 20% are discarded (a trimmed mean), which removes electrical spikes.
  2. The pin voltage is multiplied by 11 to undo the module's voltage divider.
  3. The 400 mV clean-air voltage is subtracted, and each remaining millivolt counts as 0.2 µg/m³.
  4. The last ten batch results are averaged and limited to the sensor's 0–500 µg/m³ range.

Negative ADC results, a disconnected output and a saturated input are each reported as a distinct status, never as a false reading.

DPS310 Sensor

The DPS310 is driven by the Adafruit library. Before starting it, the driver confirms the chip by reading its product ID register (0x0D must return 0x10) at address 0x77 and then 0x76. It then applies Infineon's published temperature-sensor fix and configures 4 Hz sampling with 16× oversampling.

sketch/sensors.h — PressureSensor::begin() (excerpt)

uint8_t id = 0;
if (!readRegister(addresses[i], 0x0D, id)) continue;
if (id != 0x10) { status_ = P_UNKNOWN_CHIP; continue; }
if (!drivers[i]->begin_I2C(addresses[i], &DPS310_WIRE)) { status_ = P_INIT_FAILED; continue; }
drivers[i]->setMode(DPS310_IDLE);
if (!correctTemperature(addresses[i])) { status_ = P_INIT_FAILED; return false; }
drivers[i]->configurePressure(DPS310_4HZ, DPS310_16SAMPLES);
drivers[i]->configureTemperature(DPS310_4HZ, DPS310_16SAMPLES);
drivers[i]->setMode(DPS310_CONT_PRESTEMP);

update() reads data only when the sensor's ready bits are set (register 0x08, mask 0x30). After three failed reads in a row, the sensor is marked lost and detection runs again every 10 seconds, so a sensor that is reconnected is picked up automatically.

Altitude uses the international barometric formula. The power term is calculated with a single-precision binomial series, which avoids pulling double-precision maths routines into the Cortex-M33 build.

sketch/sensors.h — altitudeFromPressure()

inline float altitudeFromPressure(float hpa) {
const float x = hpa / SEA_LEVEL_PRESSURE_HPA - 1.0f;
float term = 1.0f, ratio = 1.0f;
for (int n = 1; n <= 40; ++n) {
term *= (0.1903f - (float)(n - 1)) * x / (float)n;
ratio += term;
}
return 44330.0f * (1.0f - ratio);
}

Air score

Every pollutant is mapped to a sub-score between 100 and 0 using breakpoints based on WHO and US EPA indoor guidance, with linear interpolation between breakpoints. The overall score is the lowest sub-score, because air is only as healthy as its worst pollutant. The Python app uses the same table, and the tests check that both sides agree.

sketch/air_score.h

constexpr ScoreEdges EDGES_PM = {{0, 12, 35, 55, 150, 250}};
constexpr ScoreEdges EDGES_TVOC = {{0, 0.3f, 0.5f, 1.0f, 3.0f, 5.0f}};
constexpr ScoreEdges EDGES_HCHO = {{0, 0.03f, 0.08f, 0.3f, 0.75f, 1.0f}};
constexpr ScoreEdges EDGES_CO = {{0, 2, 4.5f, 9, 15, 35}};
constexpr ScoreEdges EDGES_IAQ = {{0, 50, 100, 150, 250, 400}};
constexpr float SUBSCORES[6] = {100, 85, 65, 45, 20, 0};
inline float subScore(float v, const ScoreEdges& edges) {
if (v <= edges.e[0]) return 100.0f;
for (int i = 1; i < 6; ++i) {
if (v <= edges.e[i]) {
const float f = (v - edges.e[i - 1]) / (edges.e[i] - edges.e[i - 1]);
return SUBSCORES[i - 1] + (SUBSCORES[i] - SUBSCORES[i - 1]) * f;
}
}
return 0.0f;
}

Light strip and safety alarms

updateAlarmsAndLight() runs once per frame. It updates the carbon-monoxide alarm with hysteresis, turns the score into a colour and passes both to the light strip. Because this runs entirely on the microcontroller, the light and alarms keep working even if the Linux app is stopped or the network is down.

sketch/sketch.ino — updateAlarmsAndLight()

void updateAlarmsAndLight() {
if (lastX6.valid) {
if (lastX6.co_ppm >= CO_ALERT_PPM) coAlarm = true;
else if (lastX6.co_ppm <= CO_ALERT_CLEAR_PPM) coAlarm = false;
}
statusLed.setAlarm(dustAlarm || coAlarm);
const int score = airScore(lastX6.valid, lastX6.iaq, lastX6.tvoc_ppm, lastX6.hcho_ppm,
lastX6.co_ppm, lastDust.valid, lastDust.density_ugm3);
uint8_t r, g, b;
scoreColor(score, r, g, b);
statusLed.setColor(r, g, b);
}

The StatusLed class redraws at 25 frames per second. Colour changes fade over 600 ms using a smoothstep curve, t² (3 − 2t), which starts and ends gently. During an alarm, the same curve drives a slow red pulse. The strip is only redrawn when the colour actually changes, which keeps the Bridge timing steady.

sketch/status_led.h — fade step

float t = (float)(now - fadeStartMs_) / (float)LED_FADE_MS;
if (t >= 1.0f) { t = 1.0f; fading_ = false; }
const float e = t * t * (3.0f - 2.0f * t);
show(mix(from_[0], target_[0], e),
mix(from_[1], target_[1], e),
mix(from_[2], target_[2], e));

Sending data to Linux

Once per second, the board sends every reading to the Linux side in a single Bridge.notify call. Every 30 seconds, a second message (atmosis_info) reports sensor health: frame counts, the DPS310 address and ID, and the dust sensor status.

sketch/sketch.ino — pushFrame()

Bridge.notify("atmosis_frame",
(int)FRAME_PROTOCOL, (int)(++frameSeq), (int)(now / 1000UL), statusFlags(),
lastX6.iaq, lastX6.tvoc_ppm, lastX6.hcho_ppm, lastX6.co_ppm,
lastX6.temperature_c, lastX6.humidity_pct,
lastDust.density_ugm3, (int)(lastDust.sensor_v * 1000.0f + 0.5f),
(int)DUST_ZERO_MV, (int)lastDust.status,
p.pressure_hpa, p.altitude_m, p.temperature_c,
pressureChipCode(), (int)loopMaxReported);

Why the link is one-way. The board registers no incoming handlers and the Linux side never sends anything back. On the UNO Q, messages sent to the microcontroller are handled on a very small thread stack, and earlier versions that sent LED and heartbeat commands to the board lost the link after a few minutes. With a one-way link that failure mode is gone, and the board stays fully self-sufficient.

Linux Application

Receiving frames

The Bridge router may not be ready when the app starts, so handler registration runs on its own thread. It retries with exponential back-off, from 1 second up to 15 seconds.

python/atmosis_sources.py — McuSource._register()

def _register(self) -> None:
pending = {"atmosis_frame": self._on_frame, "atmosis_info": self._on_info}
delay = 1.0
while pending:
for name, handler in list(pending.items()):
try:
self._bridge.provide(name, handler)
pending.pop(name)
except Exception as exc:
self.last_error = f"register {name} failed: {exc}"
if pending:
time.sleep(delay)
delay = min(delay * 2, 15.0)
self.registered = True

Every frame is validated before use. parse_frame() checks the field count and protocol version and rejects NaN or infinite values. It also range-checks the gas readings and unpacks the status flags into clear booleans. The handler then detects gaps in the sequence number (lost frames) and a falling uptime (a board restart). Frames are handed to the main loop through a bounded queue of 16. If the queue is ever full, the oldest frame is dropped, so the app always works on the newest data.

python/atmosis_sources.py — parse_frame() (excerpt)

def parse_frame(params) -> Snapshot:
p = list(params)
if len(p) != FRAME_LENGTH:
raise ValueError(f"frame needs {FRAME_LENGTH} values, got {len(p)}")
if int(p[0]) != FRAME_PROTOCOL:
raise ValueError(
f"frame protocol {p[0]} is not {FRAME_PROTOCOL} — re-upload the sketch")
values = [float(v) for v in p[4:11]] + [float(v) for v in p[14:17]]
if not all(math.isfinite(v) for v in values):
raise ValueError("frame contained a non-finite value")
flags = int(p[3])
...

If no frame arrives for 3.5 seconds, read() returns the last values marked as offline, so the dashboard shows the link state honestly rather than frozen numbers.

The per-frame pipeline

Atmosis.tick() runs once for every frame. Its order is deliberate. The reading is stored and scored first. Classification happens next. Only then do the decision steps run, each guarded by its own timing rules.

python/main.py — Atmosis.tick() (condensed)

def tick(self, snap=None):
snap = snap if snap is not None else self.source.read()
...
if snap.link_up and snap.x6_valid:
self.window.push(sample)
score, parts = environment_score(self.reading_dict(snap))
features = self.window.flatten()
if sample is not None and features is not None:
result = self.classifier.classify(features)
self._stable_label = self.smoother.update(result.label)
self._build_state(snap, result, score, parts, self._stable_label)
self.history.add({...}, now)
self._transitions(snap)
self._link_watch(snap)
self._schedule_advisory(snap, score)
self._notify_class(self._stable_label, score)
self._notify_score(score, snap)
self._check_co(snap)
self._daily_report(live)
self._service_followups()

The dust and pressure values are held between updates, so every model window always contains a complete set of nine axes. If the Environment X6 drops out for three frames in a row, the window and the smoother are cleared, so a stale pattern is never reported

Edge Impulse Inference

Atmosis uses an Edge Impulse model, trained on data recorded with these same sensors, to recognise what kind of situation is forming in the room, rather than just how high each reading is. The model classifies each window into three classes: Clean air, Indoor pollution and Poor ventilation.

Edge Impulse inference pipeline on the UNO Q.

Loading the EON export in Python

Rather than compiling the C++ library, atmosis_ei.py reads the three exported files directly: model_metadata.h, model_variables.h and tflite_learn_*_compiled.cpp. From them it extracts:

  1. the project name, window length, sampling frequency and axis order;
  2. the class labels and the Raw Data scale factor;
  3. every tensor, together with its int8 scale and zero point.

Weights and biases are de-quantised once at load time, and the result is a plain list of layers.

python/atmosis_ei.py — parse_eon_model() (excerpt)

if "extract_raw_features" not in variables:
raise EonModelError("only the Raw Data processing block is supported")
axes = [a.strip() for a in define("EI_CLASSIFIER_FUSION_AXES_STRING").split("+")]
window = define("EI_CLASSIFIER_RAW_SAMPLE_COUNT", int)
frequency = float(define("EI_CLASSIFIER_FREQUENCY"))
project = define("EI_CLASSIFIER_PROJECT_NAME")

Because every shape comes from the export itself, a retrained model is a drop-in update. Copy the three new files into python/model/ and restart the app. The loader also confirms that the model's axis order matches the order the app sends, and refuses a mismatched model instead of producing wrong answers.

Running inference

Inference is a straightforward forward pass. Each dense layer is a matrix multiplication plus bias followed by its activation, and a numerically stable softmax turns the final outputs into probabilities. With no TensorFlow runtime and no compiler, a window is classified in milliseconds on the Cortex-A53.

python/atmosis_ei.py — EdgeImpulseModel.forward() and classify()

def forward(self, x):
for layer in self._layers:
if layer["type"] == "dense":
act = layer["act"]
x = [_activate(sum(w * v for w, v in zip(row, x)) + b, act)
for row, b in zip(layer["w"], layer["b"])]
elif layer["type"] == "softmax":
m = max(x)
e = [math.exp(v - m) for v in x]
s = sum(e)
x = [v / s for v in e]
return x
def classify(self, features):
if len(features) != self.feature_count:
raise AtmosisInferenceError(
f"expected {self.feature_count} features, got {len(features)}")
probs = self.forward(self.prepare(features))
best = max(range(len(probs)), key=lambda i: probs[i])
return Classification(label=self.labels[best], confidence=probs[best], ...)

Stable, trustworthy output

A single window can be noisy, so predictions pass through two gates before anything changes on the dashboard or in Telegram:

  1. ClassificationSmoother only accepts a new label after it wins three windows in a row.
  2. _notify_class() then requires the label to hold for 60 seconds. Pattern alerts are sent at most once every 30 minutes.

python/atmosis_window.py — ClassificationSmoother.update()

def update(self, label):
if label == self._candidate:
self._votes += 1
else:
self._candidate = label
self._votes = 1
if self._votes >= self.required_votes:
self._stable = self._candidate
return self._stable

At startup, load_classifier() checks the model against four reference situations: clean air, solvent fumes, a stuffy room and smoke. The model is used only if it separates these situations. If the model files are missing or fail the check, Atmosis falls back to a pattern engine built on the same WHO and EPA thresholds, so the Air pattern card is never left empty.

Gemini 3.5 Flash Advisor

When Gemini is called

Gemini is only called when the advice is useful. needs_attention() defines when that is: a score below 60, an active CO or dust alarm, or an Edge Impulse pattern of pollution or poor ventilation. _schedule_advisory() then decides whether new advice is actually needed:

python/main.py — _schedule_advisory() (excerpt)

rank = BAND_RANK[score_band(score, True)]
first = self._advised_rank is None
worse = (not first and rank > self._advised_rank
and (now - self._adv_at) >= self.cfg.AI_ESCALATE_GAP_S)
stale = not first and (now - self._adv_at) >= self.cfg.AI_AUTO_INTERVAL_S
if first or worse or stale:
self._advised_rank = rank
self._adv_at = now
self.advisory["pending"] = True
ctx = self.advisory_context()
ctx["previous"] = self.advisory["text"] if not first else ""
self._submit("advisory", ctx)
  1. First: advice is written as soon as the air needs attention.
  2. Worse: it is refreshed only if the air moves into a worse band, and no more than once every 5 minutes.
  3. Stale: otherwise it is refreshed after 30 minutes.

When the air is healthy, no request is made and the dashboard card shows "All clear".

The Prompt

The system instruction fixes Gemini's tone and format. The user prompt is built by describe_context() and contains the score, every live reading, the Edge Impulse pattern and the user's sensitivity setting. It also includes Gemini's own previous advice, with an explicit instruction not to repeat it.

python/ai_advisor.py — system instruction

SYSTEM_PROMPT = (
"You are Atmosis, the calm indoor-air advisor inside a home "
"air-quality monitor in India. "
"Speak plainly to a non-expert. Use only the live readings given. "
"Reply in 2 to 4 short sentences, then one line that starts with "
"'Do this:' giving one practical action. No markdown, "
"no lists, no diagnosis, no mention of medical conditions."
)

As a final safeguard, a reply that is more than 85% similar to the current advice (measured with difflib.SequenceMatcher) is not shown as new.

The Request

Requests go to the Gemini REST endpoint generateContent, with the key sent in the x-goog-api-key header. Thinking is set to minimal for fast replies. If the model rejects that setting, the client steps down to low and then to no setting, and remembers what worked.

python/ai_advisor.py — _gemini_call() (excerpt)

body = {
"systemInstruction": {"parts": [{"text": SYSTEM_PROMPT}]},
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
}
if level:
body["generationConfig"] = {"thinkingConfig": {"thinkingLevel": level}}
resp = requests.post(GEMINI_URL.format(model=model),
headers={"x-goog-api-key": self.cfg.GEMINI_API_KEY,
"Content-Type": "application/json"},
json=body, timeout=timeout)

Errors are classified so each one is handled correctly:

  1. a rejected API key produces a clear message;
  2. HTTP 404 marks the model as unavailable;
  3. HTTP 429, 500, 503 and 504 are retried within the request's time budget.

Every request has a hard time budget: 30 seconds for questions and 20 seconds for Telegram tips. After a failure, the advisor backs off for 30 seconds, then 1, 2 and up to 5 minutes. Questions asked while Google is busy are queued and answered in the same chat bubble as soon as Gemini responds.

Telegram Alerts

atmosis_notify.py builds every message as a card in Telegram's HTML format. Each card has:

  1. a headline and a one-line summary;
  2. a ten-segment score bar;
  3. aligned Air quality and Climate tables inside a <pre> block;
  4. one clear action and a link to the dashboard.

Status words such as Good, Elevated and Humid use the same thresholds as the dashboard.

python/atmosis_notify.py — Telegram.send() (excerpt)

payload = {"chat_id": self.cfg.TELEGRAM_CHAT_ID, "text": text, "parse_mode": "HTML",
"link_preview_options": {"is_disabled": True}}
resp = self._post(payload)
if resp.status_code == 400 and "parse" in resp.text.lower():
resp = self._post({"chat_id": self.cfg.TELEGRAM_CHAT_ID, "text": strip_html(text)})

If Telegram ever rejects the HTML, the message is sent again as plain text, so an alert is never lost. Messages are spaced at least 1.1 seconds apart to respect Telegram's rate limits. Attention alerts include a short "✦ Gemini suggests" tip, generated by Advisor.tip().

Web API and Dashboard

Flask API

A Flask server on port 7000 serves the dashboard and a small JSON API. Every response is sent with Cache-Control: no-store, so the browser always shows fresh data.

python/main.py — /api/ask (excerpt)

@app.route("/api/ask", methods=["POST", "OPTIONS"])
def ask():
question = str((request.get_json(silent=True) or {}).get("question", "")).strip()
if not question:
return js({"error": "Type a question first."}, 400)
if len(question) > 500:
return js({"error": "Keep questions under 500 characters."}, 400)
answer, source = core.advisor.ask(core.advisory_context(), question)
if source == "gemini":
return js({"answer": answer, "source": "gemini", "followup": ""})
return js({"answer": None, "source": "busy", "followup": core.queue_followup(question)})

Dashboard

The dashboard is a single self-contained HTML file with a glassmorphism design and light and dark themes. It polls /api/status every second and events every 10 seconds. Rendering runs inside its own error guard, so a problem in one card can never stop the live updates.

python/atmosis-dashboard.html — poll()

async function poll(){
let s=null;
try{s=await api('/api/status',{},5000);S.fails=0;}
catch(e){S.fails++;}
if(s){
S.state=s;S.last=Date.now();
try{render();}catch(e){console.error('render',e);}
}
renderIsland(S.state);
setTimeout(poll,1000);
}
  1. Now tab: the score orb, nine sensor tiles with sparklines and status words, the Health advisor with chat, the Sensors card and the Air pattern card.
  2. Trends tab: history charts for every reading.
  3. Settings sheet: the user's profile and a Telegram test button.

Import the Atmosis Project

26.png

Once Arduino App Lab is installed, the complete Atmosis application can be imported directly as a ZIP file. This includes the Arduino firmware, Linux-side application, and the project configuration, so you don't need to create the application structure manually.

First, download the latest Atmosis project ZIP from the GitHub repository and save it to your computer.

Open Arduino App Lab → Import App, then select the downloaded .zip file.

App Lab will automatically extract the project and create the complete Atmosis application with its required files and structure. Once the import is finished, the project will appear in App Lab and is ready for the next configuration and deployment steps.

Configure the Project

The Atmosis project is already fully configured and ready to run, so there is no need to modify the main code or manually set up the individual components. You only need to add your own credentials in the config.py file inside the Python section of the project.

Open config.py and enter your:

  1. Gemini API key
  2. Telegram Bot Token
  3. Telegram Chat ID

Save the file once the credentials have been added.

Run Atmosis

27.png

Once the project is configured, everything is ready to run. In Arduino App Lab, simply click Run and wait for the application to start. App Lab will launch the complete Atmosis system, including the firmware, Linux application, environmental processing, and dashboard.

Once it is running, open: http://localhost:7000

You will be greeted by the fully functional Atmosis dashboard, with live environmental readings, air score, detected environmental patterns, trends, and AI-powered guidance.

Explore the Dashboard

28.png
29.png

The Atmosis dashboard is designed with an iOS-inspired interface, keeping the overall experience clean, minimal, and easy to understand while still presenting the full depth of the system.

The main view brings together the live air score, sensor readings, environmental status, detected patterns, trends, and system information in one place. This makes it easy to understand what is happening around Atmosis without having to interpret multiple raw values individually.

You can also interact directly with Gemini from the dashboard. Ask questions about the current environment, and Gemini uses the available Atmosis data to provide a contextual response and recommendation.

For a more personalised experience, the dashboard also includes light and dark themes. You can switch between them whenever you want, with both modes designed to keep the interface clear and comfortable to use.

Notifications

30.png

When Atmosis detects that something actually needs attention, it can automatically send a detailed notification through Telegram.

Each notification is designed to be clear and actionable, rather than simply sending a warning. It includes the current air score, environmental readings, climate data, detected condition, and a concise summary of what is happening.

The message also provides a clear recommendation on what to do next, such as improving ventilation or running an air purifier, along with guidance on what to avoid when the environmental conditions require attention.

This gives Atmosis another layer of accessibility: even when the dashboard isn't open, the system can proactively reach you with the important information and a practical recommendation.

Final Thoughts

Building Atmosis was ultimately a problem of turning raw environmental measurements into a reliable decision-making system.

The hardware continuously acquires multiple environmental parameters, but the real engineering challenge begins after the data is collected. The system has to validate those measurements, process them in real time, identify relationships between different variables, recognise patterns over time, and decide which events actually deserve attention.

That is why Atmosis is built as a layered system. The STM32U585 handles the deterministic, time-critical processing and safety logic. The Linux side of the Arduino UNO Q provides the computational layer for history, event processing, and machine-learning inference. Edge Impulse adds temporal pattern recognition across the sensor data, while Gemini provides a higher-level interpretation of the already-processed environmental state.

The result is a system that does not simply ask “What is the value?” It asks a more useful question: “What is changing, what does that pattern indicate, and does it require a response?”

From the sensor electronics and firmware to the ML pipeline, AI layer, dashboard, Telegram notifications, enclosure design, and long-term reliability testing, every part of Atmosis was built to work as one system.

There were plenty of failed experiments, unexpected readings, model iterations, debugging sessions, and very long nights behind the final result. But that process was important. Because the goal was never to build another device that produces more environmental data.

The goal was to build a system that can sense the environment, understand its behaviour, and turn complex data into something genuinely useful.

That is what Atmosis means to me — not just environmental monitoring, but environmental intelligence.