💻

Turing Smart Screen にアメダス情報や雨雲レーダーを表示してみる

に公開

2025/07/04 時点での情報です

Turing Smart Screen とは

PC の USB ポートに接続してシリアル通信で描画を行う小型のディスプレイです

https://akiba-pc.watch.impress.co.jp/docs/news/news/1377248.html

任意の情報(いつものアメダス)を簡単に表示できるかな~と検索してみたら以下のリポジトリがヒット

https://github.com/mathoudebine/turing-smart-screen-python

これを参考にすれば表示できそうだな~ということでアリエクで 1386 円でゲット
Amazon だと 3000 円くらい

ソースコード

上記のリポジトリに含まれている simple-program.py が非常に参考になります
simple-program.py を書き換えながらできたのが以下のソース

turing-smart-screen-amedas.py
turing-smart-screen-amedas.py
# -*- coding: utf-8 -*-
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
# https://github.com/mathoudebine/turing-smart-screen-python/

# Copyright (C) 2021-2023  Matthieu Houdebine (mathoudebine)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

# This file is a simple Python test program using the library code to display custom content on screen (see README)

import os
import signal
import sys
import time
from datetime import datetime
import threading

# Import only the modules for LCD communication
from library.lcd.lcd_comm_rev_a import LcdCommRevA, Orientation
from library.log import logger
import schedule
import requests

# Set your COM port e.g. COM3 for Windows, /dev/ttyACM0 for Linux, etc. or "AUTO" for auto-discovery
# COM_PORT = "/dev/ttyACM0"
# COM_PORT = "COM5"
COM_PORT = "AUTO"

# Display revision:
# - A      for Turing 3.5" and UsbPCMonitor 3.5"/5"
# - B      for Xuanfang 3.5" (inc. flagship)
# - C      for Turing 5"
# - D      for Kipye Qiye Smart Display 3.5"
# - SIMU   for simulated display (image written in screencap.png)
# To identify your smart screen: https://github.com/mathoudebine/turing-smart-screen-python/wiki/Hardware-revisions
REVISION = "A"

# Display width & height in pixels for portrait orientation
# /!\ Do not switch width/height here for landscape, use lcd_comm.SetOrientation below
# 320x480 for 3.5" models
# 480x480 for 2.1" models
# 480x800 for 5" models
# 480x1920 for 8.8" models
WIDTH, HEIGHT = 320, 480

assert WIDTH <= HEIGHT, "Indicate display width/height for PORTRAIT orientation: width <= height"

stop = False
contents = list()
amedastable = dict()
last_modified = None
latest_time_uri = 'https://www.jma.go.jp/bosai/amedas/data/latest_time.txt'
pre_latest_time = str()


def update():
    global amedastable, last_modified, pre_latest_time, contents

    # update amedastable if modified
    with requests.get(
            'https://www.jma.go.jp/bosai/amedas/const/amedastable.json',
            headers={
                'If-Modified-Since': last_modified,
            },
    ) as r:
        if r.status_code == 200:
            amedastable = r.json()
            last_modified = r.headers.get('Last-Modified')
            logger.info(f"amedastable updated ({last_modified})")

    # get latest time
    with requests.get(latest_time_uri) as r:
        latest_time = r.content.decode('utf-8')
        dt = datetime.strptime(latest_time, '%Y-%m-%dT%H:%M:%S%z')
        # print(dt.strftime('%Y-%m-%d %H:%M:%S'))
        yyyymmdd = dt.strftime('%Y%m%d')
        HH = dt.strftime('%H')
        hh = f'{int(HH) // 3 * 3:02d}'

    if pre_latest_time == latest_time:
        return
    logger.info(f"latest_time updated ({pre_latest_time} -> {latest_time})")
    pre_latest_time = latest_time

    # update contents
    codes = sys.argv[1:] if len(sys.argv) > 1 else ['44132']
    try:
        contents = list()
        for code in list(reversed(codes)):
            url = f'https://www.jma.go.jp/bosai/amedas/data/point/{code}/{yyyymmdd}_{hh}.json'
            with requests.get(url) as r:
                data = r.json()
                base_key = f'{yyyymmdd}{HH}0000'        # 積雪は1時間毎
                last_key = list(data.keys())[-1]
                _vars = data[base_key]
                for k in data[last_key]:
                    _vars[k] = data[last_key][k]
                    h = last_key[8:10]
                if h == '00':
                    h = '24'
                m = last_key[10:12]
                lines = [
                    amedastable[code].get('kjName', '-') + f' {h}:{m}'
                ]
                for x in [
                        'temp 度',
                        'humidity %',
                        'precipitation1h mm/h',
                        'snow cm',
                        'pressure hPa',
                ]:
                    k, u = x.split()
                    if k in _vars:
                        # print(k, _vars[k])
                        if _vars[k][1] != 0:
                            continue
                        else:
                            lines.append(f'{_vars[k][0]}{u}')
                contents.append(' '.join(lines))

        logger.info("contents updated")
    except Exception as e:
        logger.debug(e)


def runSchedule():
    global stop
    schedule.every(1).minutes.do(update)

    while not stop:
        schedule.run_pending()
        time.sleep(1)


if __name__ == "__main__":

    def sighandler(signum, frame):
        global stop
        stop = True
        task_thread.join()

    # Set the signal handlers, to send a complete frame to the LCD before exit
    signal.signal(signal.SIGINT, sighandler)
    signal.signal(signal.SIGTERM, sighandler)
    is_posix = os.name == 'posix'
    if is_posix:
        signal.signal(signal.SIGQUIT, sighandler)

    task_thread = threading.Thread(target=runSchedule, daemon=True)
    task_thread.start()
    update()

    # Build your LcdComm object based on the HW revision
    lcd_comm = LcdCommRevA(com_port=COM_PORT, display_width=WIDTH, display_height=HEIGHT)

    # Reset screen in case it was in an unstable state (screen is also cleared)
    lcd_comm.Reset()

    # Send initialization commands
    lcd_comm.InitializeComm()

    # Set brightness in % (warning: revision A display can get hot at high brightness! Keep value at 50% max for rev. A)
    lcd_comm.SetBrightness(level=10)

    # Set orientation (screen starts in Portrait)
    lcd_comm.SetOrientation(orientation=Orientation.REVERSE_LANDSCAPE)

    # Define background picture
    background = f"res/backgrounds/example_{lcd_comm.get_width()}x{lcd_comm.get_height()}.png"

    # Display sample picture
    logger.debug("setting background picture")
    start = time.perf_counter()
    lcd_comm.DisplayBitmap(background)
    end = time.perf_counter()
    logger.debug(f"background picture set (took {end - start:.3f} s)")

    # Display the current time and some progress bars as fast as possible
    while not stop:
        start = time.perf_counter()
        lcd_comm.DisplayText(datetime.now().strftime("%H:%M:%S"), HEIGHT - (12 * 8) - 2, WIDTH - 24 - 2,
                             font="res/fonts/roboto/Roboto-Bold.ttf",
                             font_size=24,
                             font_color=(255, 255, 255),
                             background_image=background)

        idx = 1
        for content in contents:
            lcd_comm.DisplayText(content,
                                 5, WIDTH - 24 - 18 * idx - 8,
                                 font="C:/Windows/Fonts/meiryob.ttc",
                                 font_size=18,
                                 font_color=(255, 255, 255),
                                 background_image=background)
            idx += 1

        end = time.perf_counter()
        # logger.debug(f"refresh done (took {end - start:.3f} s)")
        if end - start < 1:
            time.sleep(1 - (end - start))

    # Close serial connection at exit
    lcd_comm.closeSerial()

実行手順

ソースコードの取得

以下のいずれかの方法でソースコードを取得して

  • 上記のリポジトリの Releases から zip をダウンロードして展開
  • git clone https://github.com/mathoudebine/turing-smart-screen-python.git を実行
  • git clone git@github.com:mathoudebine/turing-smart-screen-python.git を実行
  • gh repo clone mathoudebine/turing-smart-screen-python を実行

その中に turing-smart-screen-amedas.py を入れます

python実行環境の構築

Python 3.12 では依存モジュールがインストールできないので 3.11 で実行環境を構築します

Python 3.11 のインストールはここからがいちばんお手軽です
https://apps.microsoft.com/detail/9NRWMJP3717K?hl=neutral&gl=JP&ocid=pdpshare

PS> python3.11.exe -m venv .venv
PS> .\.venv\Scripts\activate
(.venv) PS> python -m pip install -U pip
(.venv) PS> pip install -U -r .\requirements.txt schedule

schedule はアメダスサーバに負荷をかけないよう定期的にアメダスの情報を取得するために入れています

実行

(.venv) PS> python .\turing-smart-screen-amedas.py

以下のように表示されれば成功です(写真ヘタクソ)
アメダス東京
引数なしの場合は東京の情報を表示します

アメダスの観測地点コードを指定すると複数表示が可能です

(.venv) PS> python .\turing-smart-screen-amedas.py 14163 44132 62078 86141

14163:札幌 44132:東京 62078:大阪 86141:熊本
都道府県と観測地点を選択したときの URL の #amdno=数字 が観測地点コードになります
アメダス札幌 東京 大阪 熊本

背景画像を任意のものに簡単に変更可能(ソースを変更する必要あり)
リソース変更で任意の背景画像サンプル

背景を定期的に変更してみる

どうせなら雨雲レーダーも表示したいなあ……といろいろやってみた

amedas-zoomradar.py
amedas-zoomradar.py
# -*- coding: utf-8 -*-
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

import io
import os
import math
import signal
import sys
import threading
import time
from datetime import datetime

# Import only the modules for LCD communication
from library.lcd.lcd_comm_rev_a import LcdCommRevA, Orientation
from library.lcd.lcd_simulated import LcdSimulated
from library.log import logger
from PIL import Image
from bs4 import BeautifulSoup
import requests
import schedule

# Set your COM port e.g. COM3 for Windows, /dev/ttyACM0 for Linux, etc. or "AUTO" for auto-discovery
# COM_PORT = "/dev/ttyACM0"
# COM_PORT = "COM5"
COM_PORT = "AUTO"

# Display revision:
# - A      for Turing 3.5" and UsbPCMonitor 3.5"/5"
# - B      for Xuanfang 3.5" (inc. flagship)
# - C      for Turing 5"
# - D      for Kipye Qiye Smart Display 3.5"
# - SIMU   for simulated display (image written in screencap.png)
# To identify your smart screen: https://github.com/mathoudebine/turing-smart-screen-python/wiki/Hardware-revisions
REVISION = "A"

# Display width & height in pixels for portrait orientation
# /!\ Do not switch width/height here for landscape, use lcd_comm.SetOrientation below
# 320x480 for 3.5" models
# 480x480 for 2.1" models
# 480x800 for 5" models
# 480x1920 for 8.8" models
WIDTH, HEIGHT = 320, 480

assert WIDTH <= HEIGHT, "Indicate display width/height for PORTRAIT orientation: width <= height"

INTERVAL = 1    # minutes
stop = False
content_updated = False
radar_image_path = 'dummy'
radar_image = None
lat, lon, z = 43.055, 141.341, 8        # 札幌
amedas = str()
pre_latest_time = str()
if len(sys.argv) > 2:
    REVISION = "SIMU"


def deg2dec(deg):
    degree, minute = deg
    return degree + minute / 60


def getNearAmedas(lat, lng):
    with requests.get('https://www.jma.go.jp/bosai/amedas/const/amedastable.json') as r:
        lines = []
        data = r.json()
        for key in data:
            name = data[key]['kjName']
            elem = data[key]['elems']
            _lat = deg2dec(data[key]['lat'])
            _lng = deg2dec(data[key]['lon'])
            dist = math.dist((lat, lng), (_lat, _lng))
            # snow
            # if elem[5] == '1':
            if elem[0] == '1':
                lines.append([key, name, dist])

        return sorted(lines, key=lambda x: x[2])[0]

    return []


def updateAmedas():
    global code, loc, amedas, pre_latest_time

    # get latest time
    with requests.get('https://www.jma.go.jp/bosai/amedas/data/latest_time.txt') as r:
        latest_time = r.content.decode('utf-8')
        dt = datetime.strptime(latest_time, '%Y-%m-%dT%H:%M:%S%z')
        # print(dt.strftime('%Y-%m-%d %H:%M:%S'))
        yyyymmdd = dt.strftime('%Y%m%d')
        HH = dt.strftime('%H')
        hh = f'{int(HH) // 3 * 3:02d}'

    if pre_latest_time == latest_time:
        return

    logger.debug(f"latest_time updated ({pre_latest_time} -> {latest_time})")
    pre_latest_time = latest_time

    # update contents
    url = f'https://www.jma.go.jp/bosai/amedas/data/point/{code}/{yyyymmdd}_{hh}.json'
    with requests.get(url) as r:
        data = r.json()
        base_key = f'{yyyymmdd}{HH}0000'        # 積雪は1時間毎
        last_key = list(data.keys())[-1]
        _vars = data.get(base_key)
        # 存在しない時がある
        if _vars is None:
            return
        for k in data[last_key]:
            _vars[k] = data[last_key][k]
            h = last_key[8:10]
        if h == '00':
            h = '24'
        m = last_key[10:12]
        lines = [f'{loc} {h}:{m}']
        for x in [
                'temp 度',
                'humidity %',
                'precipitation1h mm/h',
                'snow cm',
                'pressure hPa',
        ]:
            k, u = x.split()
            if k in _vars:
                # print(k, _vars[k])
                if _vars[k][1] != 0:
                    continue
                else:
                    lines.append(f'{_vars[k][0]}{u}')
        amedas = ' '.join(lines) + '  '


def update():
    global content_updated, radar_image, lat, lon, z

    with requests.get(f"https://weather.yahoo.co.jp/weather/zoomradar/?lat={lat}&lon={lon}&z={z}") as r:
        soup = BeautifulSoup(r.content, 'html.parser')
        og_image = soup.find('meta', property='og:image')
        if og_image:
            img_url = og_image.get('content').replace('1200x630', f'{HEIGHT}x{WIDTH}')
            with requests.get(img_url) as r2:
                img = Image.open(io.BytesIO(r2.content)).convert('RGB')
                if radar_image != img:
                    radar_image = img
                    content_updated = True
                    logger.debug('radar_image updated')


def runSchedule():
    global stop
    schedule.every(INTERVAL).minutes.do(update)
    schedule.every(INTERVAL).minutes.do(updateAmedas)

    while not stop:
        schedule.run_pending()
        time.sleep(1)


try:
    lat, lon, z, _ = [float(kv.split('=')[1]) for kv in sys.argv[1].split('?')[1].split('&')]
except Exception:
    pass
code, loc, _ = getNearAmedas(lat, lon)
updateAmedas()
print(lat, lon, z, code, amedas, REVISION)


if __name__ == "__main__":
    def sighandler(signum, frame):
        global stop
        stop = True
        for th in threading.enumerate():
            if th.name != 'MainThread':
                th.join()

    # Set the signal handlers, to send a complete frame to the LCD before exit
    signal.signal(signal.SIGINT, sighandler)
    signal.signal(signal.SIGTERM, sighandler)
    is_posix = os.name == 'posix'
    if is_posix:
        signal.signal(signal.SIGQUIT, sighandler)

    task_thread = threading.Thread(target=runSchedule, daemon=True)
    task_thread.start()

    # Build your LcdComm object based on the HW revision
    if REVISION == "A":
        lcd_comm = LcdCommRevA(com_port=COM_PORT, display_width=WIDTH, display_height=HEIGHT)
    elif REVISION == "SIMU":
        lcd_comm = LcdSimulated(display_width=WIDTH, display_height=HEIGHT)
    else:
        logger.error("Unknown revision")
        try:
            sys.exit(1)
        except Exception:
            os._exit(1)

    # Reset screen in case it was in an unstable state (screen is also cleared)
    lcd_comm.Reset()

    # Send initialization commands
    lcd_comm.InitializeComm()

    # Set brightness in % (warning: revision A display can get hot at high brightness! Keep value at 50% max for rev. A)
    lcd_comm.SetBrightness(level=10)

    # Set orientation (screen starts in Portrait)
    lcd_comm.SetOrientation(orientation=Orientation.REVERSE_LANDSCAPE)

    update()

    # Display the current time and some progress bars as fast as possible
    while not stop:
        start = time.perf_counter()

        if content_updated:
            lcd_comm.DisplayPILImage(radar_image, 0, 0)
            end = time.perf_counter()
            logger.debug(f"background picture set (took {end - start:.3f} s)")
            lcd_comm.image_cache[radar_image_path] = radar_image
            content_updated = False

        lcd_comm.DisplayText(
            datetime.now().strftime("%H:%M:%S"), 0, 0,
            font="res/fonts/roboto/Roboto-Bold.ttf",
            font_size=36,
            font_color=(0, 0, 0),
            background_image=radar_image_path,
        )

        font_size = int(HEIGHT / len(amedas)) + 4
        lcd_comm.DisplayText(
            amedas, 0, WIDTH - font_size - 8,
            font="C:/Windows/Fonts/meiryob.ttc",
            font_size=font_size,
            font_color=(0, 0, 0),
            background_image=radar_image_path,
        )

        end = time.perf_counter()
        # logger.debug(f"refresh done (took {end - start:.3f} s)")
        if end - start < 1:
            time.sleep(1 - (end - start))

    # Close serial connection at exit
    lcd_comm.closeSerial()

雨雲レーダーが更新されるたびに背景を描画し直しているため、ちょっとラグが出ます……
(※revision A と呼ばれている 3.5inch 版はめっちゃ遅い 5inch版以降は速いらしいです)

(.venv) PS> python .\amedas-zoomradar.py


(※LcdSimulatedを使うことできれいなスクリーンショットを撮ることができることを知りました)

雨雲レーダーのパーマネントURLを引数に指定することで任意の地点を表示することができます

(.venv) PS> python .\amedas-zoomradar.py "https://weather.yahoo.co.jp/weather/zoomradar/?lat=35.694&lon=139.703&z=8&t=20250704191000"

今回のキモ

透過テキストを実現するために lcd_comm オブジェクトの内部変数に直接アクセスしています

            lcd_comm.image_cache[radar_image_path] = radar_image

参考情報

https://dolls.tokyo/about-usb3-5lcd-custom/
https://zenn.dev/sharl/articles/6e5eff90d70071
https://zenn.dev/sharl/articles/5470311f5617e4
https://zenn.dev/sharl/articles/c79eb7e02ca33f

最後に

非常に簡単に任意の情報を表示することができました

スタンドにはこれを使っています

Enjoy!!

Discussion