Tachyon in a Pi case

Yes. I use the 0.96” 128x64 OLED displays all the time with IoT boards for exactly the same purpose.

I’ll get the updated enclosure printed and get back to you.

For the optional battery attachable enclosure that I was referring above, I’ll share a solution that works fine with Featherwing boards. Perhaps the approach can be adapted for the Tachyon.

One of the challenge with the Tachyon batteries being used is that they are cylindrical and not flat LiPo. This makes a single case with battery larger and somewhat cumbersome. I’m going to a larger size LiPo but will also try a 2000mAh LiPo.

Thanks.

Hi Arty,

Is there a chance to re-design the case v2 (battery inside) including support for the audio board? Or there will be not enough space for it?

Thanks

Hi Tiho,

As you mentioned regarding v2 (with the battery inside), it does not have enough space for the audio board. If support for the audio board is required, the case would need to be redesigned, making it thicker.

Thank you.

Hi @Arty ,

Thank you so much for the Tachyon case with support for a single cell battery inside. Yesterday, I printed a couple of variations of the boards (tachyon-case-top-battery-oledv2.1 and tachyon-case-top-batteryv2.1). Perfect fit!

Can the Antenna go inside the case as well?

Do you know if the Particle script is available to show the Tachyon stats, as in the photo of the case above in this thread? I plan to add the same OLED to the 3D printed case. Thanks.

@zpm1066 Thanks to @Arty for this script. Let us know if you run into any issues.

# Source code for 0.96-inch SSD1306 (源码0.96寸SSD1306)

import time, socket, subprocess, os
from datetime import datetime
from luma.core.interface.serial import i2c
from luma.oled.device import ssd1306
from luma.core.render import canvas
from PIL import Image, ImageFont
import psutil

# OLED Initialization (OLED 初始化)
serial = i2c(port=2, address=0x3D)
# serial = i2c(port=2, address=0x3c)
# Screen displays correctly (positive orientation) (屏幕显示正向)
device = ssd1306(serial, width=128, height=64, rotate=0) # 0度
# If the screen is upside down, use rotate=2. (如果屏幕是反过来的,使用 rotate=2)
# device = ssd1306(serial, width=128, height=64, rotate=2)

# Function to load icons (with error handling)
# 加载图标函数(加上异常处理)
def load_icon(path):
    if os.path.exists(path):
        return Image.open(path).convert("1")
    else:
        print(f"[警告] 图标未找到: {path}")
        return Image.new("1", (16, 16))

# wifi_icon = load_icon("/home/particle/Documents/app/wifi_16_16.png")
# temp_icon = load_icon("/home/particle/Documents/app/tem_16_16.png")
# disk_icon = load_icon("/home/particle/Documents/app/disk_16_16.png")

font_emoji = ImageFont.truetype("/home/particle/Documents/app/Symbola.ttf", 13)
wifi_icon = "📶"
temp_icon = "🌡️"
disk_icon = "💾"
battery_icon = "🔋"

# You can continue using PixelOperator, but we recommend trying the fonts mentioned above and choosing the one that is clearest.
# (继续使用 PixelOperator 也可以,建议尝试上述字体,选择最清晰的)
font = ImageFont.truetype("/home/particle/Documents/app/pixel_operator/PixelOperator.ttf", 16)
# Example: Using DejaVuSansMono (示例:使用 DejaVuSansMono)
# font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16)
# font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", 14)
# Example: Using Roboto-Regular (示例:使用 Roboto-Regular)
# font = ImageFont.truetype("/home/particle/Documents/app/Roboto-Regular.ttf", 14)
# Example: Using Arial Unicode MS (示例:使用 Arial Unicode MS)
# font = ImageFont.truetype("/usr/share/fonts/truetype/msttcorefonts/Arial.ttf", 14)

# Get IP address (获取 IP)
def get_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
    except:
        ip = "0.0.0.0"
    return ip

# CPU Temperature (CPU 温度)
def get_cpu_temp():
    try:
        with open("/sys/class/thermal/thermal_zone0/temp") as f:
            return f"{int(f.read()) / 1000:.1f}C"
    except:
        return "N/A"

# Battery percentage (电池电量百分比)
def get_battery_percent():
    try:
        # 常见路径:/sys/class/power_supply/BAT0/capacity
        with open("/sys/class/power_supply/battery/capacity") as f:
            # return f"{battery_icon} {int(f.read().strip())}%"
            return f"Bat {int(f.read().strip())}%"
    except:
        return "N/A"

# Disk usage (磁盘使用率)
def get_disk_usage():
    try:
        out = subprocess.check_output(["df", "-h", "/"]).decode().split("\n")[1]
        usage_percent = out.split()[4]
        avail_gb = out.split()[3]
        return f"{usage_percent} {avail_gb}B"
    except:
        return "N/A"

def get_mem_usage():
    try:
        mem = psutil.virtual_memory()
        return f"Mem {mem.percent}%"
    except:
        return "Mem N/A"

def get_cpu_usage():
    try:
        return f"CPU {psutil.cpu_percent()}%"
    except:
        return "CPU N/A"

def get_battery_status():
    try:
        battery = psutil.sensors_battery()
        if battery is None:
            return "Bat N/A"
        status = "Charging" if battery.power_plugged else "Discharging"
        return f"{status}"
    except:
        return "Bat N/A"

def get_battery_ntc_temp():
    try:
        # Assuming the NTC temperature path is /sys/class/power_supply/battery/temp,units in 0.1°C
        # (假设NTC温度路径为 /sys/class/power_supply/battery/temp,单位0.1°C)
        with open("/sys/class/power_supply/battery/temp") as f:
            temp = int(f.read().strip()) / 10
            return f"NTC {temp:.1f}C"
    except:
        return "NTC N/A"

def get_disk_avail():
    try:
        disk = psutil.disk_usage("/")
        avail_gb = disk.free / (1024 ** 3)
        return f"Disk {avail_gb:.1f}GB"
    except:
        return "Disk N/A"

def get_date():
    return datetime.now().strftime("%Y-%m-%d")

def build_scroll_text():
    mem = get_mem_usage()
    cpu = get_cpu_usage()
    bat_status = get_battery_status()
    ntc = get_battery_ntc_temp()
    disk = get_disk_avail()
    date = get_date()
    return f" {date} | {mem} | {cpu} | {bat_status} | {ntc} | {disk}"

scroll_text = build_scroll_text()
scroll_pos = 0
text_width = len(scroll_text) * 6  # Each character is approximately 6 pixels wide. (每个字符约6像素)

# Main loop (主循环)
while True:
    now = datetime.now().strftime("%H:%M:%S")
    ip = get_ip()
    cpu = get_cpu_temp()
    battery = get_battery_percent()
    disk = get_disk_usage()

    with canvas(device) as draw:
        # Top yellow area (first 16 pixels) (顶部黄色区域(前16像素))
        draw.rectangle((0, 0, 128, 16), outline=0, fill=0) # 255
        draw.text((0, 0), "Particle", font=font, fill=255)
        # draw.text((0, 0), "Tachyon", font=font, fill=255)
        draw.text((76, 0), now, font=font, fill=255)

        # Icon + Information Area (图标+信息区域)
        # draw.bitmap((0, 16), wifi_icon, fill=255)
        draw.text((5, 14), wifi_icon, font=font_emoji, fill=255)
        draw.text((20, 14), ip, font=font, fill=255)

        # draw.bitmap((0, 32), temp_icon, fill=255)
        draw.text((5, 28), temp_icon, font=font_emoji, fill=255)
        draw.text((20, 28), cpu, font=font, fill=255)
        draw.text((60, 28), battery, font=font, fill=255)

        # draw.bitmap((0, 48), disk_icon, fill=255)
        draw.text((5, 40), disk_icon, font=font_emoji, fill=255)
        draw.text((20, 40), disk, font=font, fill=255)

        # Scrolling text (display limited to the bottom line) (滚动文字(限制显示在底部行))
        x = 128 - (scroll_pos % (text_width + 128))
        draw.text((x, 50), scroll_text, font=font, fill=255)

    scroll_pos += 4  # Scrolling speed (滚动速度)
    time.sleep(0.5)

@kmparticle , @Arty

Thank you. Much appreciated!