Code and Life

Programming, electronics and other cool tech stuff

Supported by

Supported by Picotech

Vibe coding a food diary bot in one hour

I haven't written much about the new fancy AI tools in the blog, so I thought I'll do a short writeup on a sample workflow through ChatGPT, VS Code with GitHub Copilot and Claude Code that resulted in a fully working food diary app in about one hour from the idea.

The Problem Statement

I decided keeping a food diary would be nice way to avoid unhealthy habits. I experimented with using ChatGPT's vision capabilities some time ago to do calorie estimates, and that worked pretty well, so I thought I'd combine a simple Google Sheet and occasional ChatGPT use for a 95 % working solution without needing to bother with installing an app that would likely want to overhaul my whole diet, and subscribe me to a $19.99/mo plan while at it.

However, manually firing up the Google Sheet when on computer (the mobile version is crummy) wasn't ideal, so enter ChatGPT -- maybe I could do a native Android client with AI calorie estimates easily? So I asked GPT-5:

I'd like to create a super simple food diary app on my Android phone:

  1. Add a diary entry either by writing what I ate, taking a picture, or both
  2. Gemini API used for a calorie estimate from AI
  3. Diary stored permanently on my server (maybe including the pics?)

Outline for me three different architectures. I have a Raspberry Pi available, and a cloud server. I'd like minimal backend solution for storage which I can also view manually (say JSON or SQLite or even Google Sheet). Native app would be fun, but at least something I can quickly fire up from phone and upload photo.

After a while of thought, GPT5 kindly gave me three ideas, to summarize:

  • Telegram bot + tiny API on your Pi/cloud (fastest to use from phone)
  • Google Sheets + Apps Script Web App (no server to run)
  • Native Android app (Kotlin) + tiny API on Pi/cloud (most control, best UX)

Now the first one was actually pretty smart. I had recently done a Telegram bot for a IoT project and it was quite easy, and it would readily come with picture sending and chat capabilities. That would be plenty.

Initial version: The Spec

Continuing with the prompt, I gave a sketch of how the interaction could go:

I think the telegram bot sounds nice. I'd like to be able to group messages within a short period to impact the same entry, so the interaction with bot could be like:

> Had a pasta carbonara for lunch

< That's about 700 calories

> [image]

< Based on your description and photo, that's about 900 calories

> 850 cal

< Thanks, adjusted based on your input.

And on the AI went. I got these artifacts out:

  1. FastAPI server (SQLite + media + Gemini)
import os, uuid, re, io, csv, time, datetime
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, UploadFile, Form, File, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
import sqlite3, json

# --- config ---
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
MEDIA_DIR = DATA_DIR / "media"
DB_PATH = DATA_DIR / "entries.db"
AUTH_TOKEN = os.getenv("API_TOKEN", "dev-secret")
GROUP_WINDOW_SECONDS = int(os.getenv("GROUP_WINDOW_SECONDS", "600"))  # 10 min
GEMINI_KEY = os.getenv("GEMINI_API_KEY", "")

app = FastAPI()

# And on it goes...
  1. Telegram bot (groups messages, supports “850 cal”)
import os, re, requests, tempfile
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, ContextTypes, filters

BOT_TOKEN = os.environ["TELEGRAM_TOKEN"]
API_BASE = os.getenv("API_BASE", "http://localhost:8080")
API_TOKEN = os.getenv("API_TOKEN", "dev-secret")
CAL_RE = re.compile(r"^\s*(\d{2,5})\s*(k?cal)?\s*$", re.I)

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Food diary ready. Send text, photo, or both. Say '850 cal' to override.")

# And so on...

Now running two things on my Raspberry Pi 4 and having them communicate sounds a bit involved, maybe I could combine them?

Could we combine the bot and backend code to single executable? Let's simplify storage even more:

  1. Accumulate text and photos, take timestamp on first new message
  2. Once user inputs "xxx cal" that will "close" the current entry and flush to disk

JSONL could be just {'sent': '2025-08-15 14:40 UTC', 'description': 'All text concatenated here', images: [/* local path(s) */], calories: 123}

And basically, with this input I got a working single-file Python code for a Telegram bot I could just run with:

pip install python-telegram-bot==21.*
export TELEGRAM_TOKEN="your-bot-token"
python fooddiary_bot.py

I have skipped the part where I made a new Telegram Bot using Telegram's BotFather — ask your local AI for info if you want to know how to do that. And no, I am not generating this text with AI even though there was an mdash right there.

Making a project with UV

Python dependencies are irksome, and creating a separate virtual environment felt a bit heavy. I recalled this thing called UV had pretty much a combination of venvs, poetry and what npm does in Node projects. I even had it installed on my Macbook so I could skip the one-liner in the docs.

To be frank I forgot if there were other alternatives, so this is how I got started with ChatGPT:

What's a modern python package management solution that would allow me to install the telegram bot

And out it spit instructions:

# Install uv (single binary)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create project
uv init fooddiary-bot
cd fooddiary-bot

# Install deps in isolated venv
uv add python-telegram-bot

# Run
uv run python fooddiary_bot.py

Well, almost ready, I wasn't fully inspired by fooddiary-bot, descriptive as it was. So I turned again to my AI friend. And after not being fully happy with initial suggestions, prompted it a bit more and settled on imaginative and, according to Google, not very popular existing project name: mealgram.

Mealgram 1.0: Configuration handling

Replacing the project name, I had it mostly working. However, specifying my Telegram token on every run was suboptimal, so I fired up VS Code and asked the resident GitHub Copilot model (probably GPT 4.1):

I'd like to store the telegram token and gemini api key in a suitable env file

Basically got instructions to pip install python-dotenv which I replaced with uv add python-dotenv and add the lines:

from dotenv import load_dotenv
load_dotenv()

Writing these lines into the file seemed like too much work, so I just pasted my token to .env file, switched from Ask to Edit mode (which frustratingly in VS Code deletes the context) and asked:

I installed python-dotenv and set the TELEGRAM_TOKEN in my .env, can you modify the code

Now I could just uv run main.py and the bot worked. Neat.

Making it a github project

So far I had ran this locally, but it would be nice to brag about the vibe coding project and maybe even someone would be interested in the actual product. So I created a new github repo at https://github.com/jokkebk/mealgram and chose MIT license (this is not rocket science so why not give it away free).

Locally I basically ran (thanks to modern git which has main and not master so github won't complain):

$ git init
$ echo .env >> .gitignore
$ echo "data/" >> .gitignore
$ git add .
$ git commit -a -m "Initial commit"

Now since I chose to create a repo with LICENSE in there, I now have two repos without common base I need to merge. Never remember how to do it. Not a problem, Claude Code to the rescue!

$ claude

# Yes I'm fine with this folder...

> I already create github repo for this at https://github.com/jokkebk/mealgram -- there is already a LICENSE file there, can you add remote origin and push the local stuff there and merge any conflicts?

And Claude Code tried to push, noticed there are separate repository bases, fixed it for me and voila: We're live!

Adding pizzazz: Gemini calorie estimates

Now Gemini 2.5 Flash is a pretty cheap and powerful model. I am already using it in my personal budget app to turn web bank screenshots to TSV-formatted statements. So I returned from Claude Code to VS Code and had some hopes for Claude 3.7 Thinking (didn't have version 4 enabled at the time):

Can you add /cal command that would feed text and image(s) to Google Gemini. Use the python-genai library I have installed. GEMINI_API_KEY variable is set in .env

(yes I had ran uv add python-genai before this)

Now Claude did what most LLMs with knowledge cutoffs do: Wrote me a non-working piece of code using an older Google LLM API.

Fixing it

I tried simply:

Google genai is used wrong, here's a proper new version snippet:

from google import genai

# TODO(developer): Update below line
API_KEY = "YOUR_API_KEY"

client = genai.Client(vertexai=True, api_key=API_KEY)

response = client.models.generate_content(
    model="gemini-2.5-flash",
    contents="Explain bubble sort to me.",
)

print(response.text)

Well, that went nowhere. Here was the most manual part of the exercise as I needed to actually change the top-of-file import, mark the function that was wrong, and kindly nudge it again there. After that, it worked well.

Lo and behold, restarting the bot gave me a working /cal command that ran fine with both text and images. Don't know if the estimates are any good, but it's AI so it's awesome, right?

Migrating to server

At this point I was ready to git commit and git push my changes, log into my pi4 box and git clone the thing. A few scp commands to copy the .env and already written data/entries.jsonl over, and it was working.

Now one thing was a bit bummer, I had already two days worth of data in the Google Sheet. Well, not a problem really:

$ claude

> Check out the format of @data/entries.jsonl and prepend this data there:

  Päivä    Ruoka    kcal
  13/08/2025    2 aamupalaleipää suolakurkulla    350
  ...

● I'll examine the format of the entries.jsonl file and prepend the provided data.

● Read(data/entries.jsonl)
  ⎿  Read 4 lines (ctrl+r to expand)

● Now I'll convert the provided data to match the JSON format and prepend it to the file:

● Update(data/entries.jsonl)
  ⎿  Updated data/entries.jsonl with 8 additions
        1 +  {"sent": "2025-08-13 12:00 UTC", "description": "2 aamupalaleipää suolakurkulla", "images": [], "calories": 350}
        ...

● The data has been successfully prepended to the entries.jsonl file. I converted the tabular data to match the JSON format used in the file, with each
  entry containing a timestamp, description, empty images array, and calorie count.

Lovely modern tech.

Final touches: Simple reporting

I could always go goggle the entries.jsonl if I wanted reporting or build something nice, but maybe I could have a simple per-day summary in the bot? Back to VS Code:

One more command, /stats should go through the JSONL and print total calories for latest 7 days logged (regardless if they are consecutive or not -- just sum each distinct day and print max 7 latest entries)

After it made the addition (which worked), I decided /report would actually be a nicer command and edited that manually. Pushed the changes and now the app was pretty much complete.

Summary

With ChatGPT, VS Code + GitHub Copilot, and Claude Code I was able to build a fully working food diary bot with a GitHub repository and some nice basic functionalities, plus a Google Gemini Flash 2.5 powered AI calorie estimation in about an hour. Most time went to debug the legacy SDK Claude 3.7 decided to use for Gemini. Not bad for a quick work!

Take a look at the project: https://github.com/jokkebk/mealgram

Read post

Using Wemos S2 Pico ESP32-S2 serial on Arduino

Wemos S2 pico board with ESP32-S2

Just a quick note after yesterday's S2 Pico OLED tutorial. I encountered some hiccups getting Serial.println() to work on Arduino with this board. Essentially nothing would come out of Serial Monitor after reprogramming.

I think the core of the issue is that the ESP32-S2 has native USB. ESP8266 and older ESP32 boards used a USB serial converter chip, so programming over serial vs. printing to serial happened without any glitches to the USB. Now with native USB I think here's what happens:

  1. You press Button 0, cycle Reset and release B0
  2. ESP32-S2 boots into "programming mode" and initializes native USB as COM port
  3. You hear the USB connection sound as COM port is assigned
  4. Arduino reprograms the flash
  5. You manually press reset
  6. USB COM port actually drops at this point
  7. When you have Serial.begin(); in your code, it now initializes native USB as COM port again
  8. You hear the "USB chime" again from your computer, and COM port is assigned

Now if you're used to having Arduino Serial monitor open all the time, the same COM13 that was there during programming on my PC is now a "new" COM13. It seems the serial monitor doesn't notice the change. Solution is simple:

  1. Reprogram your chip.
  2. Reset, wait for the "chime"
  3. Only now open the serial monitor

The irksome thing is, that I'll now need a delay in setup() to see what's going on. Maybe I have an old version of Arduino or something. If you know another solution, you're welcome to drop me a line (me at codeandlife.com)

Read post

Turn Comments into Fish Shell Commands with OpenAI

Notice: I wanted to see if OpenAI canvas can do reasonable Markdown editing, so this post is co-written with ChatGPT 4o with Canvas. The code and Fish script were done before writing this separately with the gracious help of our AI overlords as well. I've kept the prose to minimum and edited the result myself, so benefit should still be high, even though manually written content is low.

Recently, I wanted to make my command-line experience a bit more conversational. Imagine writing a comment like # list files, pressing enter, and seeing it magically turn into the corresponding Fish shell command: ls. With OpenAI's API, this becomes not just possible but surprisingly straightforward. And should rid me of jumping to ChatGPT every time I need to remember how find or let alone ffmpeg exactly works.

This blog post walks through creating a Python script called shai that turns natural language comments into Unix commands using OpenAI's API, and then utilizing that script with a Fish shell function to replace a comment written on command line with the actual command. After the command is generated, you can edit it before running it — a nice way to integrate AI without losing control.

Setting up the environment

Before we dive into the script, make sure you have the following:

  1. Python installed (version 3.8 or higher is recommended).

  2. An OpenAI API key. If you don’t have one, sign up at OpenAI.

  3. The OpenAI Python library in a Python virtual environment (or adjust the code below if you prefer something else like pip install openai on your global env):

    $ python3 -m venv /home/joonas/venvs/myenv
    $ source /home/joonas/venvs/myenv/bin/activate.fish # or just activate with bash
    $ pip install openai
    
  4. A configuration file named openai.ini with your API key and model settings, structured like this:

    [shai]
    api_key = your-openai-api-key
    model = gpt-4o-mini
    

The Python script

Here’s the Python script, shai, that interprets natural language descriptions and returns Unix commands:

#!/home/joonas/venvs/myenv/bin/python

import os
import sys
from openai import OpenAI
import configparser

# Read the configuration file
config = configparser.ConfigParser()
config.read('/home/joonas/openai.ini')

# Initialize the OpenAI client with your API key
client = OpenAI(api_key=config['shai']['api_key'])

def get_unix_command(natural_language_description):
    # Define the system prompt for the model
    system_prompt = (
        "You are an assistant that converts natural language descriptions of tasks into "
        "concise, accurate Unix commands. Always output only the Unix command without any "
        "additional explanations or text. Your response must be a single Unix command."
    )

    # Call the OpenAI API with the description
    response = client.chat.completions.create(
        model=config['shai']['model'],
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": natural_language_description},
        ],
        temperature=0,  # To ensure consistent and accurate output
    )
    
    # Extract the command from the response
    command = response.choices[0].message.content.strip()
    return command

def main():
    if len(sys.argv) < 2:
        print("Usage: shai <natural language description>")
        sys.exit(1)
    
    # Get the natural language description from command line arguments
    description = " ".join(sys.argv[1:])
    try:
        # Generate the Unix command
        unix_command = get_unix_command(description)
        print(unix_command)
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    main()

How it works

  1. Configuration: The script reads an openai.ini file for API credentials and model settings.
  2. Command generation: When you provide a natural language description, the script sends it to OpenAI’s API along with a system prompt specifying the desired output format.
  3. Output: The script returns the corresponding Unix command.

You could place it in e.g. ~/bin and do chmod +x shai to make it runnable, and then test it:

$ shai list files
ls

Extending to Fish shell

To make this functionality seamlessly available in the Fish shell, you can use the following Fish function:

function transform_comment_line
    set cmd (commandline)
    # Check if line starts with a hash (a comment)
    if string match -q "#*" $cmd
        # Remove the '#' and possible leading space
        set query (string trim (string sub -s 2 $cmd))

        # Run your "shai" script (replace 'shai' with the actual command)
        # Assuming that 'shai' takes the query as arguments and prints the command
        set result (shai $query)

        # Replace the current command line with the output of 'shai'
        commandline -r $result
        # Now your command line is replaced with the generated command.
        # The user can edit it further if needed, and press Enter again to run.
    else
        # If it's not a comment line, just execute normally
        commandline -f execute
    end
end

Save this function in your Fish configuration directory as .config/fish/functions/transform_comment_line.fish. Then, bind it to a key or trigger it manually to convert comments into executable commands. I am using this in my .config/fish/config.fish to automatically run on enter:

if status is-interactive
    # Commands to run in interactive sessions can go here
    bind \r transform_comment_line
end

And that is literally it. Enjoy!

Ending was edited for brevity, ChatGPT wanted to rant on how this could become a powerful part of your workflow...

Read post

Using The SSD1306 OLED on a Wemos S2 Pico ESP32-S2 board

Wemos S2 pico board with ESP32-S2

Just received the Wemos S2 pico board from AliExpress, and thought I'd write a simple tutorial on how to use it with Arduino, as Wemos' Getting started guide was a bit outdated on Arduino config and did not have an OLED example.

Quick Background

I've been planning to make a DIY hardware Bitcoin wallet just for fun. To make it even remotely secure — once you assume attackers have your internet connected devices pwned it pretty much varying degrees of tinfoil — it's essential that you have an external display and a button to print out your secret key or which address you're signing your coins to go.

My ESP8266 supply was running low (have been using ), and not sure if it has enough memory, I looked what Wemos might have nowadays, since I've used their nice D1 Mini in several projects, such as the ATX power control. I was very happy to discover they had this Wemos S2 Pico available at a reasonable 8 € price point from LoLin AliExpress store , having an SSD-1306 compatible OLED display and even a button. Perfect!

Note: there are clones for Wemos products for cheaper, but I like to show my support even if it costs a dollar or two more!

Setting up Arduino for ESP32-S2 Support

Following Wemos' Getting Started tutorial, I realized the Boards list did not include any ESP32-S2 modules. I checked that I had the "latest" 1.0.6 version installed. After some googling lead me to this Adafruit page, I realised that I needed 2.0.x version that is served from a different location (latest ESP32 branch now lives in Github).

After following the installation instructions — essentially replacing the old Espressif "Additional Boards Manager URL" in Arduino Preferences with the new one — I updated the ESP32 package to 2.0.1 and voilà: There is now the "ESP32S2 Dev Module" available in the ESP32 Boards section. Since Wemos' instructions, the USB CDC setting had changed a bit, this is how I set it up (changes made highlighted):

Arduino Board Configuration for Wemos S2 Pico ESP32-S2

Note that the S2 Pico requires you to hold Button 0 down, press Reset button and release the Button 0 to enter flashing mode. This will change the COM port! Thankfully, it seems to stay in that mode so you should not be in a rush to flash.

Using The OLED on Wemos S2 Pico

Read post

How to upgrade ollama docker image without losing models

After a bit of AI hiatus, I noticed that llama 3.0 models were released and wanted to try the models. Sure enough, after a week the weights we re available at the official site. However, the Docker image hasn't been used in a while and I wanted to upgrade it without losing the models.

There was almost no information on this available online yet, and even the ollama docker documentation is quite non-existent — maybe for seasoned Docker users it is obvious what needs to be done? But not for me, so let's see if I can manage it.

Upgrading the docker image

First, let's just upgrade the ollama/ollama image:

$ sudo docker pull ollama/ollama

This is nice, but the currently running container is still the old one. Let's stop it:

$ sudo docker stop ollama

Checking the location of the files

I remember I set a custom directory to store the models. Let's check where it is:

$ sudo docker inspect ollama | grep -i volume
            "VolumeDriver": "",
            "VolumesFrom": null,
                "Type": "volume",
                "Source": "/mnt/scratch/docker/volumes/ollama/_data",
            "Volumes": null,

As can be seen, the models are stored in /mnt/scratch/docker/volumes/ollama/_data. Let's make a hard-linked copy of the files into another folder, to make sure we don't lose them:

$ sudo bash
$ cd /mnt/scratch
$ cp -al docker/volumes/ollama/_data ollama_backup

Now that we have the models backed up, let's remove the old container:

Read post

Code and Life site updated to 11ty

This site has been migrated from Wordpress to 11ty based static site. I took the posts, categories, tags and comments as JSON data and made the necessary templates for conversion. Everything should be a lot faster now.

The look is still a bit bare, and some things like tables seem a bit broken. Will address these issues hopefully during upcoming days, weeks and months. Enjoy!

PS. Comments are currently disabled, I was only receiving spam in any case. You can check out my homepage at https://joonaspihlajamaa.com/ if you want to contact me.

Read post