Making the Invisible Perceptible

Concept

Phillip’s eyelids grow heavy. His thoughts flow as slowly as honey. All he wants is to sleep. Just sleep. THUD!!! What was once the floor is now the wall. But what happened?

The culprit is invisible indoor air pollution. As modern buildings become increasingly airtight, gases such as CO₂, carbon monoxide and volatile organic compounds can accumulate, affecting our health and cognitive performance. We rely on technology to regulate our surroundings, yet ventilation systems are often inadequate, poorly calibrated or simply too slow. So why don’t we just open the damn window?

The problem is invisibility. Our senses cannot reliably detect deteriorating air quality until its effects become noticeable. What we need is a sensory prosthesis: “Toxic Phillip”. In true human fashion, we turn to technology to save us from technology.

Toxic Phillip is a small, circular wall-mounted device featuring Homo sedens — the sitting human. It translates invisible air quality data into visual, kinetic and acoustic signals, creating three levels of perception:

20 metres — Ambient awareness
An indirect glow communicates air quality: green for good, yellow for moderate and red for poor.

10 metres — Kinetic warning
As air quality deteriorates, Phillip gradually leans backwards. At a critical threshold, he suddenly tips over, accompanied by a sad little sound. An unmistakable reminder to open the window.

0.5 metres — Precise information
An integrated display reveals air quality measurements in ppm, temperature and relative humidity.

By combining humour, technology and human perception, Toxic Phillip makes the invisible impossible to ignore. Whether you’re studying, working or sleeping… the fall of Phillip precedes your own.

Prototype

Future developments

Future iterations of Toxic Phillip will focus on higher-quality natural materials, a more precise PPM sensor, and a simplified string-based mechanism. Different design variations will also be explored to refine Phillip’s appearance and adaptability to different spaces.

Bill of materials

Schematic

Code

#include <Wire.h> // I2C connection (display + DHT20)
#include <Servo.h>
#include <U8x8lib.h>
#include <MQ135.h>
#include “DHT.h” // temperature and humidity sensor
//Each line brings in a “cookbook” of ready-made commands.
// Without them, the Arduino wouldn’t know what Phillip.write() or u8x8.print() mean.
// pins
#define GASSENSOR_PIN A1
#define BUZZER_PIN 5
#define SERVO_PIN 2
#define LED_RED 7
#define LED_YELLOW 8
#define LED_GREEN 9
// display and climate sensor: I2C ports, no pin needed
// your calibration value in fresh air
#define RZERO 250
// ppm values
#define PPM_CLEAN 750 // fresh air: Phillip fully upright
#define LIMIT_GOOD 100 // below: good (green)
#define LIMIT_MEDIUM 1500 // below: medium (yellow)
#define LIMIT_BAD 2500 // below: bad (red), above: toxic (red, Phillip falls)
// servo angles
#define ANGLE_GOOD 180 // upright, at PPM_CLEAN
#define ANGLE_BAD 120 // lowest angle of the smooth part
#define ANGLE_TOXIC 85 // fallen over
// timing
#define SENSOR_INTERVAL 5 // read sensors every 5 ms
#define SERVO_INTERVAL 5 // move servo one degree every 5 ms
// smoothing: how much a new value counts (0.0 to 1.0)
//new measurement counts 20 %, previous value keeps 80 %. calms the jumpy sensor values.
#define SMOOTHING 0.05
// fonts
#define FONT_SMALL u8x8_font_chroma48medium8_r
#define FONT_LARGE u8x8_font_px437wyse700b_2x2_r
// definition of the names ob the screen and sensors.
Servo Phillip;
U8X8_SSD1306_128X64_NONAME_HW_I2C u8x8(U8X8_PIN_NONE);
MQ135 gasSensor(GASSENSOR_PIN, RZERO);
DHT dht(DHT20);
// global variables: values are written outside setup() and loop(), so they keep their value the whole time and every part of the sketch can use them.
float smoothPpm = PPM_CLEAN; // smoothed gas value
int currentAngle = ANGLE_GOOD; // where Phillip is now //
int targetAngle = ANGLE_GOOD; // where Phillip should go
unsigned long lastSensorTime = 0; // when the sensors were last read
unsigned long lastServoTime = 0; // when the servo last moved
bool wasToxic = false; // was the air already toxic last time?
void setup() {
// starting the connection
Serial.begin(9600);
Serial.println(“Phillip is awake”);
Wire.begin();
dht.begin();
//setting the outputs
pinMode(BUZZER_PIN, OUTPUT);
pinMode(LED_RED, OUTPUT);
pinMode(LED_YELLOW, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
setTrafficLight(LOW, LOW, HIGH); // starting with green
Phillip.attach(SERVO_PIN); //connects the servo to pin 2
moveServo(currentAngle); //turns him to 180° (ANGEL_GOOD)
// prepare display: start it, flip it, small font, clear it
u8x8.begin(); //
u8x8.setFlipMode(1);
u8x8.setFont(FONT_SMALL);
u8x8.clear();
// labels, printed only once (never change)
u8x8.setCursor(0, 5);
u8x8.print(“Temp:”);
u8x8.setCursor(0, 6);
u8x8.print(“Hum:”);
}
// 2 loops both ran at the same time
void loop() {
// every 5 ms: read sensors, set target
// PART 1 : SENSORS
//“Has enough time passed since the last reading? Then note the time and read again.”
if (millis() – lastSensorTime >= SENSOR_INTERVAL) { //millis is a stopwatch, a dely would stop everything
lastSensorTime = millis();
float climate[2]; // climate[0] = humidity, climate[1] = temperature
float ppm;
// PART 1.1: temperature and humidity (wenn die Climawerte nicht = 0 sind, werden sie auf dem display gescreen, andernfalls wird error angezeigt)
if (dht.readTempAndHumidity(climate) == 0) {
float humidity = climate[0];
float temperature = climate[1];
ppm = gasSensor.getCorrectedPPM(temperature, humidity);
u8x8.setCursor(6, 5);
u8x8.print(temperature, 1);
u8x8.print(” C “);
u8x8.setCursor(6, 6);
u8x8.print(humidity, 0);
u8x8.print(” % “);
u8x8.setCursor(15, 0);
u8x8.print(“*”); // star = corrected value
} else {
ppm = gasSensor.getPPM(); // fallback without correction
u8x8.setCursor(6, 5);
u8x8.print(“error “);
u8x8.setCursor(6, 6);
u8x8.print(” “);
u8x8.setCursor(15, 0);
u8x8.print(” “); // no star = uncorrected value
}
// PART 1.2: smoothing (the old ppm value counds 80% and the new ppm vale is counting 20%(connected to sensor interval))
if (!isinf(ppm)) {
smoothPpm = smoothPpm * (1.0 – SMOOTHING) + ppm * SMOOTHING;
}
// rows 0 and 1: gas value, large
u8x8.setFont(FONT_LARGE);
u8x8.setCursor(0, 0);
u8x8.print(smoothPpm, 0);
u8x8.print(” “); // clears leftover digits
u8x8.setFont(FONT_SMALL);
// target angle and sad sound
if (smoothPpm < LIMIT_BAD) {
targetAngle = map((long)smoothPpm, PPM_CLEAN, LIMIT_BAD, ANGLE_GOOD, ANGLE_BAD);
targetAngle = constrain(targetAngle, ANGLE_BAD, ANGLE_GOOD);
wasToxic = false; // air is OK again
} else {
// toxic: fall over at once, no gliding
targetAngle = ANGLE_TOXIC;
currentAngle = ANGLE_TOXIC;
moveServo(currentAngle);
if (!wasToxic) { // only at the moment of falling
playSadSound();
wasToxic = true;
}
}
// row 3: stage as text, and the traffic light
u8x8.setCursor(0, 3);
if (smoothPpm < LIMIT_GOOD) {
u8x8.print(“Air: good “);
setTrafficLight(LOW, LOW, HIGH); // green
} else if (smoothPpm < LIMIT_MEDIUM) {
u8x8.print(“Air: medium “);
setTrafficLight(LOW, HIGH, LOW); // yellow
} else if (smoothPpm < LIMIT_BAD) {
u8x8.print(“Air: bad “);
setTrafficLight(HIGH, LOW, LOW); // red
} else {
u8x8.print(“Air: TOXIC “);
setTrafficLight(HIGH, LOW, LOW); // red
}
}
//PART 2: every … ms: glide one degree towards the target
if (millis() – lastServoTime >= SERVO_INTERVAL) {
lastServoTime = millis();
if (currentAngle < targetAngle) {
currentAngle++;
moveServo(currentAngle);
} else if (currentAngle > targetAngle) {
currentAngle–;
moveServo(currentAngle);
}
}
}
// moves Phillip and reports the angle to grasshopper
void moveServo(int angle) {
Phillip.write(angle);
Serial.println(angle);
}
// switches the three lights: HIGH = on, LOW = off
void setTrafficLight(int red, int yellow, int green) {
digitalWrite(LED_RED, red);
digitalWrite(LED_YELLOW, yellow);
digitalWrite(LED_GREEN, green);
}
// sad falling melody with a wobbly last note
void playSadSound() {
int notes[] = {392, 370, 349}; // falling notes in Hz
int lengths[] = {350, 350, 350}; // length of each note in ms
for (int i = 0; i < 3; i++) {
tone(BUZZER_PIN, notes[i]);
delay(lengths[i]);
noTone(BUZZER_PIN);
delay(60); // short gap between notes
}
// last note: long and wobbly
for (int i = 0; i < 10; i++) {
tone(BUZZER_PIN, 330);
delay(60);
tone(BUZZER_PIN, 318);
delay(60);
}
noTone(BUZZER_PIN);
}