It all started — like many great ideas — with coffee. Or rather, the lack of it.

In our office, we kept running into the same annoyance: you’d walk up to the coffee machine, full of caffeine dreams, only to find it mid-cleaning cycle. 30 minutes of nothing but frustration.

There had to be a better way.

The Idea That Was Born at the Coffee Machine

One day, someone joked:

“Wouldn’t it be nice if we could see from our desks whether the machine is cleaning or not?”

Sadly, our coffee machine doesn’t come with an API - even though it’s a Pro-User product. But we had a spare Raspberry Pi and an old webcam lying around. And just like that, a tiny coffee-fueled hackathon began.

Step 1: Camera + Motion

We mounted the webcam on the coffee machine and installed Motion on the Raspberry Pi. It gave us a simple web interface that streamed frames from the camera — perfect for keeping an eye on the machine’s display.

The webcam attached to the coffee machine (Top right)

From there, it was easy to tell manually whether the “Cleaning” box was on screen or not. Go to your browser, type the Raspberry Pi’s IP and have a look at the webcam.

Good first step. But having to check the browser every time? Still not ideal.

So, back to the drawing board…

Image description

Step 2: Image Classification with TensorFlow

We needed automation.

The camera position is fixed, thus it should give us a great playground to differentiate between images.

So I fired up TensorFlow and started training a tiny neural network. I collected screenshots from the camera feed — some with the cleaning screen visible, some without.

With just a few dozen examples per category, the model got surprisingly reliable at classifying whether the machine was in cleaning mode or ready for action.

A small excerpt of what i had to cope with:

Someone grabbing a cup, a colleague messing up my test set, the cleaning box, and no box at all (TL to BR)

Step 3: A Slackbot With Attitude

The last missing piece: notifications.

As the coffee machine is the hardest worker in most offices, plain messages would’ve been boring. So the bot got a bit of personality.

I used ChatGPT to generate a few, here are the ones that I integrated:

messages = [
    "🧼 I'm at the spa. It's called luxury. For you: cleaning mode.",
    "😶‍🌫️ Too much of your love is stuck on me. Let me scrub that off.",
    "🛁 Spa day for me. You guys take a break.",
    "😐 No, I'm not broken. I'm busy with a bubble bath.",
    "🧽 I'm cleaning myself. Why? Because you treat me like an animal.",
    "💤 Cleaning in progress. Please don't knock, I'm sensitive.",
    "🙃 Hygiene is important. That's what my therapist told me.",
    "🚿 I'm showering. You should try it too.",
    "🤖 Maintenance ongoing. Mood? Somewhere between resigned and soapy.",
    "🫣 I'm getting rid of your coffee sins. Don't look."
] if is_positive else [
    "☕ Ready for the next coffee. Wishing you a strong day!",
    "🧼 Freshly cleaned and ready to go. Let's do this – you're awesome!",
    "🔧 I'm back in shape. Time for your next energy boost.",
    "📎 System's running. This day is bound to go well, right?",
    "😌 Clean, ready – and in a peaceful coffee mood.",
    "☀️ Good morning! I'm ready. Are you?",
    "👋 Back online. I believe in you (and caffeine).",
    "🥸 Ready to brew. Let's do great things – or at least stay awake.",
    "🌞 Ready to brighten your day. Coffee coming right up.",
    "📈 All set. May your day run as smoothly as I do now."
]

The Code

I had a fairly simple setup: One python script to train the model, a folder with my dataset (box / no_box), and a python script to detect and run within a systemd service:

The directory structure

train.py

from tensorflow.keras import layers, models
import numpy as np
import cv2
import os
from sklearn.model_selection import train_test_split

def load_images_from_folder(folder, label):
    images = []
    labels = []
    for filename in os.listdir(folder):
        img_path = os.path.join(folder, filename)
        img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
        if img is not None:
            img = cv2.resize(img, (128, 128)) # Resizing the image to a uniform size
            images.append(img)
            labels.append(label)
    return images, labels

def load_dataset():
    images_with_box, labels_with_box = load_images_from_folder("dataset/box", 1)
    images_without_box, labels_without_box = load_images_from_folder("dataset/no_box", 0)

    images = np.array(images_with_box + images_without_box)
    labels = np.array(labels_with_box + labels_without_box)

    images = images / 255.0
    images = images.reshape(-1, 128, 128, 1)

    return train_test_split(images, labels, test_size=0.2, random_state=42)

def build_model():
    model = models.Sequential([
        layers.Conv2D(32, (3,3), activation='relu', input_shape=(128, 128, 1)),
        layers.MaxPooling2D((2,2)),
        layers.Conv2D(64, (3,3), activation='relu'),
        layers.MaxPooling2D((2,2)),
        layers.Flatten(),
        layers.Dense(128, activation='relu'),
        layers.Dense(1, activation='sigmoid')
    ])

    model.compile(optimizer='adam',
                  loss='binary_crossentropy',
                  metrics=['accuracy'])
    return model

print("Start")

# Loading test data
X_train, X_test, y_train, y_test = load_dataset()

# Creating the model
model = build_model()

# Training the model
model.fit(X_train, y_train, epochs=10, validation_data=(X_test, y_test))

model.save("box_detector_model.h5")

print("Training completed!")

detect.py

import time
import http.client
import requests
import tensorflow as tf
import numpy as np
import json
import cv2
import os
import random
from datetime import datetime

tf.get_logger().setLevel('INFO')

# A simple hysteresis threshold to stop the script from spamming

DETECTION_TIME_THRESHOLD = 40.0 # Seconds with box, before the status changes
ABSENCE_TIME_THRESHOLD = 30.0 # Seconds without box, before the status changes

SLACK_TOKEN = "xoxb-...."
SLACK_CHANNEL = "#your-channel-name"

SAVE_DIR = "./false_positives"

os.makedirs(SAVE_DIR, exist_ok=True)

def send_slack_message(message):
    conn = http.client.HTTPSConnection("slack.com")

    headers = {
        "Authorization": f"Bearer {SLACK_TOKEN}",
        "Content-Type": "application/json",
    }

    data = json.dumps({
        "channel": SLACK_CHANNEL,
        "text": message,
    })

    conn.request("POST", "/api/chat.postMessage", body=data, headers=headers)

    response = conn.getresponse()
    if response.status != 200:
        print(f"Error sending the slack message: {response.read().decode()}")
    else:
        response_data = json.loads(response.read().decode())
        if not response_data.get("ok"):
            print(f"Error sending the slack message: {response_data}")

    conn.close()

def capture_and_predict(model, url):
    response = requests.get(url, stream=True)
    if response.status_code == 200:
        img_array = np.asarray(bytearray(response.content), dtype=np.uint8)
        img_color = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
        img_gray = cv2.cvtColor(img_color, cv2.COLOR_BGR2GRAY)
        img_resized = cv2.resize(img_gray, (128, 128)) / 255.0
        img_input = np.expand_dims(img_resized, axis=-1)
        img_input = np.expand_dims(img_input, axis=0)
        prediction = model.predict(img_input, verbose=0)[0][0]
        return prediction > 0.5, img_color
    else:
        print("Error fetching the image.")
        return None, None

_last_message = None

def get_coffee_message(is_positive=True):
    global _last_message

    messages = [
        "🧼 I'm at the spa. It's called luxury. For you: cleaning mode.",
        "😶‍🌫️ Too much of your love is stuck on me. Let me scrub that off.",
        "🛁 Spa day for me. You guys take a break.",
        "😐 No, I'm not broken. I'm busy with a bubble bath.",
        "🧽 I'm cleaning myself. Why? Because you treat me like an animal.",
        "💤 Cleaning in progress. Please don't knock, I'm sensitive.",
        "🙃 Hygiene is important. That's what my therapist told me.",
        "🚿 I'm showering. You should try it too.",
        "🤖 Maintenance ongoing. Mood? Somewhere between resigned and soapy.",
        "🫣 I'm getting rid of your coffee sins. Don't look."
    ] if is_positive else [
        "☕ Ready for the next coffee. Wishing you a strong day!",
        "🧼 Freshly cleaned and ready to go. Let's do this – you're awesome!",
        "🔧 I'm back in shape. Time for your next energy boost.",
        "📎 System's running. This day is bound to go well, right?",
        "😌 Clean, ready – and in a peaceful coffee mood.",
        "☀️ Good morning! I'm ready. Are you?",
        "👋 Back online. I believe in you (and caffeine).",
        "🥸 Ready to brew. Let's do great things – or at least stay awake.",
        "🌞 Ready to brighten your day. Coffee coming right up.",
        "📈 All set. May your day run as smoothly as I do now."
    ]

    message = random.choice(messages)

    while message == _last_message:
        message = random.choice(messages)

    _last_message = message
    return message

# Loading the model
model = tf.keras.models.load_model("box_detector_model.h5")

box_detected = False
last_detection_time = None
last_absence_time = None

previous_state = None

live_image_url = "http:///current"

while True:
    detected, last_image = capture_and_predict(model, live_image_url)
    current_time = time.time()

    if detected != previous_state:
        print('box detected' if detected else 'no box')
        previous_state = detected

    if detected is None:
        time.sleep(1)
        continue

    if detected:
        last_absence_time = None  # reset the opposite tracker

        if not box_detected:
            if last_detection_time is None:
                last_detection_time = current_time
            elif current_time - last_detection_time >= DETECTION_TIME_THRESHOLD:
                box_detected = True
                send_slack_message(get_coffee_message(True))

                # 💾 Bild lokal speichern
                if last_image is not None:
                    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
                    filename = f"{SAVE_DIR}/box_{timestamp}.jpg"
                    cv2.imwrite(filename, last_image)
                    print(f"Image saved: {filename}")
        else:
            # Box already detected, do nothing special
            pass

    else:
        last_detection_time = None  # reset the opposite tracker

        if box_detected:
            if last_absence_time is None:
                last_absence_time = current_time
            elif current_time - last_absence_time >= ABSENCE_TIME_THRESHOLD:
                box_detected = False
                send_slack_message(get_coffee_message(False))
        else:
            # Box already not detected, do nothing special
            pass

    time.sleep(1)

The Result: A Smarter Office (and Happier Humans)

What started as a spontaneous joke turned into a full-blown IoT project with real impact on daily happiness — and a sprinkle of machine learning magic.

The CoffeeCam is a reminder that tech doesn’t have to be massive or complex to be useful. Sometimes, all it takes is a Pi, a webcam, and a good dose of curiosity.

And now, I’ll get myself a cup of coffee. Cheers!