R36S レトロゲーム機2025/12/13

Linux のゲームエミュレータが入ってて安いということで買ってみました。
Raspberry Pi3でもBatceraとかでできましたが、ちょっとパワー不足で、かといってRaspberry Pi5
とかは高いので中華レトロゲーム機に興味が出ました。
調べるとオリジナルではないものがあるとのことで、アリエクの BOYHOM Store で購入。¥4100

ここにオリジナルかクローンの見分け方があります。
https://handhelds.wiki/R36S_Clones#Avoid_buying_a_clone

箱からはなんか違うような。。。

一応ArkOSが搭載されていました。

裏面の"Consola Retro"がさらに怪しい

電池の蓋をあけて基板のバージョンを確認。V22で今のところ一番新しいものらしい

ここのサイトでパネルの確認をしたら、Panel4と出ました
https://aeolusux.github.io/ArkOS-R3XS/tools/dtbIdentify.htm

オリジナルらしい。


●SDカードの品質が悪いとのことで、すかさずイメージバックアップ&EASYROMSをバックアップ
怪しげなSDカードです

Fedora PCでイメージバックアップ
”ディスク”アプリでSDカードを選択

右にあるメニュー(・が三つの)”ディスクイメージを作成”を選択

適当に名前を付けて作成開始
xxx.imgファイルができます。
このイメージファイルを使って、新しい同容量のSDカードにイメージを書き込み
イメージファイルを右クリックして、イメージライターを選択
”なし”のところで新しいSDカードを選択→リストア開始
これで気兼ねなく遊べます。

●WIFIでアップデート
Raspberry Pi で使っていたWIFIドングルが使えないか試してみました。
だいぶ前に買ったGW-USNano2というものです。これをセリアで買ったUSB変換アダプターでつけてみたところ、問題なく接続できました。
ダイソーで売ってる変換アダプターや¥550のType-CのUSBハブでも使えました。


WIFIの設定はOPTIONのWIFIで。
UPDATEもここから
WIFIが使えるようになると WIFI:up になります。

以前、DSの脳トレを散々やってDS本体が壊れてしまって放置していたのですが、これでやってみたのですが、マウスで文字を書くのは至難の業です。
ダイソーのハブにWIFI、マウスを繋いで動作することは確認できました。USB DACも使えました。


●ARKOSを最初からインストール
ゲームを入れるSDカードとOSを入れるカードは分けたほうが良いと聞いたので、新しいSDカードにArkOSをインストール。

おすすめの microSD カード
https://handhelds.wiki/R36S_Compatibility_Lists

ですが、試しに¥550のダイソー32GBmicroSDで作成

ARKOSダウンロード
https://github.com/AeolusUX/ArkOS-R3XS?tab=readme-ov-file

imgファイルをFedora のディスクアプリでSDカードに書き込んで、R36Sに戻して起動

WindowsではRaspberry Pi imager を使ってみました
今回はRaspberryPiのイメージは作らないので "No Filterig" を選択して次に 

"カスタムイメージを使う" を選択して書き込むimgファイルを選択して次に

書き込むSDカードを選択して次に、Writingに進むので書き込み

本体に戻して起動するとインストールされます。といってもSDカード内に展開されるので、SDカードを変えればいろいろ試せます。

OPTIONSでデバイスを選択

※V22ボードは以下からdtbファイルを入れ替えないとUSB OTGが動かない問題がありました。
以下の対処でWIFIなどの機器が動作するようになりました。

https://handhelds.wiki/R36S_Overview#R36S-V22_2024-12-18

dtb files
https://github.com/AeolusUX/R36S-DTB/tree/main/R36S/Panel%204%20-%20V22

別のSDカードにゲームを入れる
新しいSDカードをexFATでフォーマットして、EASYROMSフォルダの中身(元のゲームが入っているSDカードのフォルダ)をコピーします。
これをR36SのTF2-GAMEスロットに挿して、OPTIONS→ADVANCED→SWITCH TO MAIN SD FOR ROMSを選択して切り替え。

●portmaster install
https://portmaster.games/installation.html
Downloadから以下をゲット

Install.Full.PortMaster.sh

SDカードのtoolsフォルダにコピーする
ネットから必要ファイルがダウンロードされるので、WIFIドングルを繋ぐ
本体に戻してOPTIONS->Tools->INSTALL.FULL.PORTMASTER インストール

PORTMASTER起動 OPTIONS->Tools->PORTMASTER

Optionで日本語も選べます

●kodi install
この記事にKODIに関するものがあったので試してみました。
https://www.reddit.com/r/R36S/comments/1c1mzz4/kodi_on_r36s/?tl=ja

kodi-Installer.rar を取ってきて解凍。
中身の
kodi Installer.sh
kodi.tar.gz
2つのファイルをゲームを入れてあるSDカードにコピー。
コピー先フォルダ: ports
(この2つのファイルはインストール後に消える)
R36Sを起動してメニューのPORTからKODI Installを実行するとインストールが開始される。
結構時間がかかる。
再起動するとKODIの項目がメニューにできる。

KODIを起動するとスペイン語なので設定を変更。
まずスペイン語が分からないので、なんとか英語にする。
左の三つあるアイコンの真ん中が設定(画像はもうすでに英語になってます)
Interface選択
Interface Langage -> English

いきなり日本語にしない理由は以前Raspberry PiでKODIを使っていた時にフォントを”Arial”にしておかないと文字が豆腐になってなんだかわからなくなったので、念のためSkinからFontsをArial にします。

Interface Langageが英語のままでもフォントをArialにすると日本語が表示できます。


DACを繋げばより良い音で聴けます。メディアプレイヤーとしても使えるかも。。。

YouTube埋め込みテスト2025/12/05

YouTubeの埋め込みのテストです。 ブログをHTMLで書かないといけないのは面倒ですね。

画像

2023年のゴールデンウィークの前後に松之山温泉でおてつたびをして、休日観光した際に撮った動画 おてつたび先の”ひなの宿ちとせ”はとても良い人たちと出会えていい経験をしました。

Spotpear ESP32C3-1.442025/11/12

Spotpear ESP32C3-1.44

カラーLCDの使い方を調べるのに”Spotpear ESP32C3-1.44inch” をアリエクで買ってみました。¥1000くらい。 

とりあえず買ったままの状態で起動すると説明が出てくるので、
スマホでテザリング設定で"Spotpear" , password "12345678"
でWifiに接続して一応通常画面が出る。が、時間は合わないし、天気情報もでませんでした。

●Demo  CodeをArduino IDEでコンパイルする。
コンパイル方法

コードの在処
https://github.com/Spotpear/ESP32C3_1.44inch

1】以下のライブラリをインストール
 TFT_eSPI
 TJpg_ Decoder
 ArduinoJson
 Time
 HTTPClient
 lvgl

2】ライブラリ内のTFT_eSPIフォルダのUser_Setup.h を編集する
以下の設定にする
-------------------------------------------------------------------------------------------------------
#define ST7735_DRIVER

#define TFT_WIDTH  128
#define TFT_HEIGHT 128

#define ST7735_GREENTAB3

#define TFT_RGB_ORDER TFT_BGR  // Colour order Blue-Green-Red

#define TFT_INVERSION_ON

#define TFT_MOSI  4
#define TFT_SCLK  3
#define TFT_CS    2  // Chip select control pin
#define TFT_DC    0  // Data Command control pin
#define TFT_RST   5  // Reset pin (could connect to RST pin)
#define TFT_MISO -2 //dummy これ追加(重要)

#define SPI_FREQUENCY  40000000
-------------------------------------------------------------------------------------------------------
Webのspotpearの説明にはありませんが、TFT_MISO に値を入れないとTFT_INITが失敗します。
TFT_eSPI_ESP32_C3.h に不具合があります。TFT_eSPIのバージョンは2.5.43。
出所
https://github.com/Bodmer/TFT_eSPI/issues/3772

・TFT_eSPIのバグ
1,User_Setup.hでTFT_MISOを定義しない、または-1を設定すると TFT_MISI == TFT_MISO となって初期化できない。画面白いまま。
2,TFT_eSPI/Processors/TFT_eSPI_ESP32_C3.h で
#define SPI_PORT_SPI2_HOST
 #define SPI_PORT 2
にしないとCore Dumpが出ます。

TFT_MISO に-2を入れてますが、-1ではなく、存在しないPin番号にしたかったので適当に設定しました。これが良いか不明。

--------------------------------------------------------------------------------------------------------------------------------------------
User_Setup.h
//                            USER DEFINED SETTINGS
//   Set driver type, fonts to be loaded, pins used and SPI control method etc.
//
//   See the User_Setup_Select.h file if you wish to be able to define multiple
//   setups and then easily select which setup file is used by the compiler.
//
//   If this file is edited correctly then all the library example sketches should
//   run without the need to make any more changes for a particular hardware setup!
//   Note that some sketches are designed for a particular TFT pixel width/height

// User defined information reported by "Read_User_Setup" test & diagnostics example
#define USER_SETUP_INFO "User_Setup"

// Config for two ST7735 128 x 128 displays for Animated_Eyes example
#define USER_SETUP_ID 47

#define ST7735_DRIVER     // Configure all registers

#define TFT_WIDTH  128
#define TFT_HEIGHT 128

// #define ST7735_INITB
// #define ST7735_GREENTAB
// #define ST7735_GREENTAB2
 #define ST7735_GREENTAB3
// #define ST7735_GREENTAB128    // For 128 x 128 display
// #define ST7735_GREENTAB160x80 // For 160 x 80 display (BGR, inverted, 26 offset)
// #define ST7735_REDTAB
//#define ST7735_BLACKTAB
// #define ST7735_REDTAB160x80   // For 160 x 80 display with 24 pixel offset

//#define TFT_RGB_ORDER TFT_RGB  // Colour order Red-Green-Blue
#define TFT_RGB_ORDER TFT_BGR  // Colour order Blue-Green-Red

#define TFT_INVERSION_ON
//#define TFT_INVERSION_OFF

// Generic ESP32 setup

//add for spotpear esp32-c3 1.44
#define TFT_MOSI  4
#define TFT_SCLK  3
#define TFT_CS    2  // Chip select control pin
#define TFT_DC    0  // Data Command control pin
#define TFT_RST   5  // Reset pin (could connect to RST pin)
#define TFT_MISO -2 //dummy

//#define TFT_MISO 19
//#define TFT_MOSI 23
//#define TFT_SCLK 18
////#define TFT_CS    21 // Not defined here, chip select is managed by sketch
//#define TFT_DC    2
//#define TFT_RST   4  // Connect reset to ensure display initialises

#define LOAD_GLCD   // Font 1. Original Adafruit 8 pixel font needs ~1820 bytes in FLASH
#define LOAD_FONT2  // Font 2. Small 16 pixel high font, needs ~3534 bytes in FLASH, 96 characters
#define LOAD_FONT4  // Font 4. Medium 26 pixel high font, needs ~5848 bytes in FLASH, 96 characters
#define LOAD_FONT6  // Font 6. Large 48 pixel font, needs ~2666 bytes in FLASH, only characters 1234567890:-.apm
#define LOAD_FONT7  // Font 7. 7 segment 48 pixel font, needs ~2438 bytes in FLASH, only characters 1234567890:.
#define LOAD_FONT8  // Font 8. Large 75 pixel font needs ~3256 bytes in FLASH, only characters 1234567890:-.
//#define LOAD_FONT8N // Font 8. Alternative to Font 8 above, slightly narrower, so 3 digits fit a 160 pixel TFT
#define LOAD_GFXFF  // FreeFonts. Include access to the 48 Adafruit_GFX free fonts FF1 to FF48 and custom fonts

#define SMOOTH_FONT


//#define SPI_FREQUENCY  27000000
#define SPI_FREQUENCY  40000000

#define SPI_READ_FREQUENCY  20000000

#define SPI_TOUCH_FREQUENCY  2500000

// #define SUPPORT_TRANSACTIONS
--------------------------------------------------------------------------------------------------------------------------------------------

3】lvglライブラリの lv_conf.h の作成
 lvglライブラリは9.4.0(現時点で最新)から8.4.0にダウングレードした。lv_conf.h がだいぶ違う。9.x.xはエラーがたくさんでて面倒なので。

lv_conf_template.h をコピーして名前をlv_conf.hにしてlibrariesフォルダーに入れる
lv_conf.hを編集する
(編集されたものがDemo codeに入ってるのでそのまま使える)
●---Modify the parameter to change 0 to 1 here
/* clang-format off */
#if 1 /*Set it to "1" to enable content*/ <- 1に 

#ifndef LV_CONF_H
#define LV_CONF_H

#include <stdint.h>

/*====================
   COLOR SETTINGS
 *====================*/

/*Color depth: 1 (1 byte per pixel), 8 (RGB332), 16 (RGB565), 32 (ARGB8888)*/
#define LV_COLOR_DEPTH 16

/*Swap the 2 bytes of RGB565 color. Useful if the display has an 8-bit interface (e.g. SPI)*/
#define LV_COLOR_16_SWAP 0 <- 付属のヘッダーファイルは0だが、Webの説明では1 

●--- Change 30 to 16 and 0 to 1 here
/*====================
   HAL SETTINGS
 *====================*/

/*Default display refresh period. LVG will redraw changed areas with this period time*/
#define LV_DISP_DEF_REFR_PERIOD 16      /*[ms]*/ <- 16に

/*Input device read period in milliseconds*/
#define LV_INDEV_DEF_READ_PERIOD 30     /*[ms]*/

/*Use a custom tick source that tells the elapsed time in milliseconds.
 *It removes the need to manually update the tick with `lv_tick_inc()`)*/
#define LV_TICK_CUSTOM 1 <- 1に
#if LV_TICK_CUSTOM
    #define LV_TICK_CUSTOM_INCLUDE "Arduino.h"         /*Header for the system time function*/
    #define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis())    /*Expression evaluating to current system time in ms*/
#endif   /*LV_TICK_CUSTOM*/

/*Default Dot Per Inch. Used to initialize default sizes such as widgets sized, style paddings.
 *(Not so important, you can adjust it to modify default sizes and spaces)*/
#define LV_DPI_DEF 130     /*[px/inch]*/

※ライブラリのバグにたどり着くまで、1日あまり費やしました。githubの皆さんに感謝。
これでTFT_eSPIライブラリが使えるようになりました。


Freenove Arduino用六脚ロボットキット2025/10/31

アリエクで Freenove  6脚ロボットキットを買って組み立てました。(¥12,638コントローラ付き)
箱が結構つぶれていましたが、中身は大丈夫でした。


とりあえず動きました。動画では超音波センサーがついていますが、キットには含まれません。
Wifi経由でスマホアプリやパソコン用のProcessingというアプリでコントロールできます。

ロボット本体のCPUは ATNEGA2560-16AU 
GPIOのヘッダーがあるのでセンサーなどが取り付けられます。

写真にあるヘッダーに超音波センサーを付けて、障害物をよけるようにしました。
●配線
HC-SR04 -> Header pin
Trig -> 2pin
Echo -> 3pin
Vcc -> 3.3V
Gnd -> GND
●動作
1,前足右を2回振る
2,前進
3,物が20cm以内なら左に回転
4,5回左に回転しても物がある場合、スリープ

謎な点
センサープログラムをロボットに組み込んでみたら、1回目に値がおかしい場合がある。ワークアラウンド:とりあえず2回読む。

●スケッチ
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
#ifndef ARDUINO_AVR_MEGA2560
#error Wrong board. Please choose "Arduino/Genuino Mega or Mega 2560"
#endif

// Include FNHR (Freenove Hexapod Robot) library
#include <FNHR.h>

// ====================== 設定 ======================
const uint8_t TRIG_PIN = 2;   // 出力 (TRIG)
const uint8_t ECHO_PIN = 3;   // 入力 (ECHO)

//#define DEBUG_SERIAL

FNHR robot;

void setup() {
  // Custom setup code start
#ifdef DEBUG_SERIAL
  Serial.begin(9600);
#endif
  // 測距センサー
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
  digitalWrite(TRIG_PIN, LOW);          // まず LOW にしておく

  // Custom setup code end
  // Start Freenove Hexapod Robot
  robot.Start();
  //前足を振る
  leg_move();
  leg_move();

}

void loop() {
  // Custom loop code start
  static unsigned int count = 0;

  long distance = measureDistance();
  delay(5);
  distance = measureDistance();
#ifdef DEBUG_SERIAL
  if (distance >= 0) {                 // -1 はエラー
    Serial.print("Distance: ");
    Serial.print(distance);
    Serial.println(" cm");
  } else {
    Serial.println("Out of range / timeout");
  }
#endif

  if (distance > 20) {  
  // Crawl forward
  crowl_forward(4);
  count = 0;
  }
  else {
    // Turn left
    turn_left(4);
    delay(1000);

    ++count;
    if (count > 4) {
      robot.SleepMode();
      count = 0;
      while (true); //stop
    }
  }
}

//動作
void leg_move() {
  // Active mode
  robot.ActiveMode();
  delay(500);

  robot.LegMoveToRelatively(1, 10, 60, 50);
  delay(200);

  robot.LegMoveToRelatively(1, -20, -10, -10);
  delay(100);
  robot.LegMoveToRelatively(1, 40, 10, 10);
  delay(100);
  robot.LegMoveToRelatively(1, -40, -10, -10);
  delay(100);
  robot.LegMoveToRelatively(1, 20, 10, 10);
  delay(200);

  robot.LegMoveToRelatively(1, -10, -60, -50);

}

void crowl_forward(unsigned int count) {
  for (unsigned int i = 0;i < count;i++) {
    // Crawl forward
    robot.CrawlForward();
  }
}

void crowl_backward(unsigned int count) {
  for (unsigned int i = 0;i < count;i++) {
    // Crawl Backward
    robot.CrawlBackward();
  }
}

void crowl_left(unsigned int count) {
  for (unsigned int i = 0;i < count;i++) {
    // Crawl Backward
    robot.CrawlLeft();
  }
}

void crowl_right(unsigned int count) {
  for (unsigned int i = 0;i < count;i++) {
    // Crawl Backward
    robot.CrawlRight();
  }
}

void turn_left(unsigned int count) {
  for (unsigned int i = 0;i < count;i++) {
    robot.TurnLeft();
  }
}

// ====================== 測定関数 ======================
long measureDistance() {
  // 1. TRIG を 10µs HIGH にする
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  // 2. ECHO パルス幅を取得(max 30 ms = 30000 µs)
  //    timeout_ms を設定して無限待ちにならないように
  const uint32_t timeout_us = 30000;   // 30 ms
  uint32_t pulse = pulseIn(ECHO_PIN, HIGH, timeout_us);

  if (pulse == 0) return -1;           // タイムアウト

  // 3. 距離を cm に変換
  long distance_cm = pulse / 58;       // 1cm ≈ 58µs(往復)
  return distance_cm;
}

LM Studioでスケッチの改善2025/08/29

前回作成した Web Radio のスケッチで、局名と曲名を2段、スクロールするのに同じコードを2つ書いていて無駄だなと思っておりました。C++でかっこよく複数使える関数にしたいと思い、LM Studio というローカルで使えるAIに聞いてみました。LLMは gpt-oss-20b を使いました。


意図が分かってもらえたようで、良さそうな回答が得られました。
ただグラフィックまわりは変だったので直してこんな感じでできました。
ボードはESP32-C3 & 128x32 SSD1306 Display
できたスケッチはこちら
ScrollText.h
/*********************************************************************
 *  ScrollText.h
 *********************************************************************/
#pragma once

#include <Adafruit_GFX.h>          // 既存の画面描画ライブラリ
#include <U8g2_for_Adafruit_GFX.h>

// ------------------------------------------------------------------
// 一行分をスクロールさせる構造体
struct ScrollItem {
    String text;          // 表示したい文字列
    int   x;              // 現在のX座標(左端)
    int   y;              // Y座標(固定で1行ごとにずらす)
    int   speed;          // 1フレームあたりのスクロール量 (px)
    int   minX;           // テキストが画面外に完全に出る位置
    const uint8_t *font;  // フォントポインタ(U8g2フォント)
};

// ------------------------------------------------------------------
// 複数行を管理するクラス
class Scroller {
public:
    Scroller(U8G2_FOR_ADAFRUIT_GFX &gfx, int displayW)
        : gfx(gfx), width(displayW) {}

    // 1行追加。y は任意に決めるか、内部で自動計算する。
    void addText(const String& txt,
                 int y,
                 int speed = -2,
                 const uint8_t *font = u8g2_font_unifont_t_japanese1) {
        ScrollItem itm;
        itm.text  = txt;
        itm.y     = y;
        itm.speed = speed;
        itm.font  = font;

        // 初期位置: 画面右端
        itm.x = width;

        // 最小X(テキストが完全に左側へ消える)=文字数 * 6(px) + 1px余白
        //   ※ 6 px はフォントの幅。必要なら変更。
        itm.minX = - (int)(txt.length() * 6);

        items.push_back(itm);
    }

    // 1フレーム分更新+描画
    void update() {
        for (auto &itm : items) {
            // フォント設定
            gfx.setFontMode(1);          // 透明背景
            gfx.setFont(itm.font);

            // 描画位置を設定
            gfx.setCursor(itm.x, itm.y);
            gfx.print(itm.text);

            // スクロール処理
            itm.x += itm.speed;   // speed は負数で左へ

            // 画面外に完全に出たら右側へ戻す(ループ再表示)
            if (itm.x < itm.minX) {
                itm.x = width;
            }
        }
    }

private:
    U8G2_FOR_ADAFRUIT_GFX &gfx;          // 画面描画オブジェクト
    int                     width;       // 画面幅(px)
    std::vector<ScrollItem> items;       // 複数行管理
};


本体
#include <Adafruit_SSD1306.h>
#include <U8g2_for_Adafruit_GFX.h>
#include "ScrollText.h"

const uint8_t I2C_SDA = 0;   //   for C3mini
const uint8_t I2C_SCL = 1;   //

#define SCREEN_WIDTH 128
//#define SCREEN_HEIGHT 64
#define SCREEN_HEIGHT 32
#define OLED_RESET     -1
#define SCREEN_ADDRESS      (0x3C)

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
U8G2_FOR_ADAFRUIT_GFX u8g2_for_adafruit_gfx;

Scroller scroller(u8g2_for_adafruit_gfx, SCREEN_WIDTH);

void setup() {
    Serial.begin(115200);

      Wire.begin(I2C_SDA, I2C_SCL);

    if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) { // I²Cアドレス
        Serial.println(F("SSD1306 allocation failed"));
        for (;;); // 無限ループ
    }
    u8g2_for_adafruit_gfx.begin(display);                 // connect u8g2 procedures to Adafruit GFX

    display.clearDisplay();

    // 例: 2 行スクロール
#if 0
    scroller.addText(F("Hello World! This is line 1."), 16);
    scroller.addText(F("Second line scrolling left."), 32, -3);
#else
    scroller.addText(F("Hello World! This is line 1."), 16);
    scroller.addText(F("日本語テスト"), 32-1, -3, u8g2_font_unifont_t_japanese1);
#endif
    // 任意にフォント変更したい場合は第4引数で指定
    // scroller.addText(F("日本語テスト"), 48, -2,
    //                  u8g2_font_unifont_t_japanese1);
}

void loop() {
    Serial.println(F("loop"));
    display.clearDisplay();
    scroller.update();   // 1フレーム分を描画
    display.display();
    delay(50);           // ~20fps
}


短時間でなかなかよいものができたと思いました。
AI恐るべし。。。

2025/8/30 更新
組み込んで使ってみたら、addしたtextが変更できないことがわかりました。追加しかできない。
それで変更しました。

/*********************************************************************
 *  ScrollText.h
 *********************************************************************/
#pragma once

#include <Adafruit_GFX.h>          // 既存の画面描画ライブラリ
#include <U8g2_for_Adafruit_GFX.h>

//
// ------------------------------------------------------------------
//
class ScrollingText {
public:
    // コンストラクタ: 描画オブジェクトと初期Y座標を受け取る
    ScrollingText(U8G2_FOR_ADAFRUIT_GFX &gfx, int displayW, int y,
                  const uint8_t *font = u8g2_font_unifont_t_japanese1,
                  int speed = -2)
        : gfx(gfx), width(displayW), y(y), font(font), speed(speed) {
        x    = 0;          // 初期位置は任意(ここでは画面右端に設定する)
        minX = 0;          // 以降 setText() で更新
    }

    // テキストをセットすると、minX と初期 X を計算
    void setText(const String &message) {
        msg   = message;
        minX  = -6 * msg.length();     // 6px × 2(フォントサイズ?) = 12px
        x     = width;
    }

    // 1フレーム分更新+描画(呼び出し側で loop() 内に入れる)
    void update(void) {
        if (msg.isEmpty()) return;      // テキストが設定されていなければ何もしない
        // フォント設定
        gfx.setFontMode(1);          // 透明背景
        gfx.setFont(font);

        // 描画位置を設定
        gfx.setCursor(x, y);
        gfx.print(msg);

        // スクロール処理
        x += speed;   // speed は負数で左へ

        // 画面外に完全に出たら右側へ戻す(ループ再表示)
        if (x < minX) {
            x = width;
        }
    }

private:
    U8G2_FOR_ADAFRUIT_GFX &gfx;
    int      width;       // 画面幅(px)
    String msg;          // 現在表示中の文字列
    int   x, y, minX;
    const uint8_t *font;
    int   speed;
};


本体
#include <Adafruit_SSD1306.h>
#include <U8g2_for_Adafruit_GFX.h>
#include "ScrollText.h"

const uint8_t I2C_SDA = 0;   //   for C3mini
const uint8_t I2C_SCL = 1;   //

#define SCREEN_WIDTH 128
//#define SCREEN_HEIGHT 64
#define SCREEN_HEIGHT 32
#define OLED_RESET     -1
#define SCREEN_ADDRESS      (0x3C)

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
U8G2_FOR_ADAFRUIT_GFX u8g2_for_adafruit_gfx;

ScrollingText scroller1(u8g2_for_adafruit_gfx, SCREEN_WIDTH, 16, u8g2_font_unifont_t_japanese1, -2); // Y=16px
ScrollingText scroller2(u8g2_for_adafruit_gfx, SCREEN_WIDTH, 32-1, u8g2_font_unifont_t_japanese1, -4); // Y=31px

void setup() {
    Serial.begin(115200);

    Wire.begin(I2C_SDA, I2C_SCL);

    if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
        Serial.println(F("SSD1306 allocation failed"));
        for (;;);
    }
    u8g2_for_adafruit_gfx.begin(display);                 // connect u8g2 procedures to Adafruit GFX

    display.clearDisplay();

    scroller1.setText(F("長い文字列をスクロール表示します。")); // 任意の文字列
    scroller2.setText(F("日本語表示。")); // 任意の文字列
}

void loop() {
    display.clearDisplay();
    scroller1.update();   // 1フレーム分描画&スクロール
    scroller2.update();   // 1フレーム分描画&スクロール
    display.display();
    delay(50);           // 約20fps
}

WebRadio に組み込み
#include <Wire.h>
//#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <U8g2_for_Adafruit_GFX.h>
#include <WiFiMulti.h>
#include "WiFi.h"
#include "Audio.h"
#include "Rotary.h"
#include "ScrollText.h"

enum STATE { NORMAL, MENU, VOLUME, FUNCTION };
STATE state = NORMAL;  

// Enconder PINs
#define ENCODER_PIN_A 1
#define ENCODER_PIN_B 2
#define PUSH_SW      21

// Declaration for an SSD1306 display connected to I2C (SDA, SCL pins)
// The pins for I2C are defined by the Wire-library.
const uint8_t I2C_SDA = 18;   //
const uint8_t I2C_SCL = 17;   //

#define SCREEN_WIDTH        (128)
#define SCREEN_HEIGHT       (64)
//#define SCREEN_HEIGHT       (32)
#define VUPOS_X     (96)
#define VUPOS_Y     (1)
#define VOLPOS_X     (8)
#define VOLPOS_Y     (22)
// 8×8 のビットマップ(例:笑顔アイコン)
const uint8_t smiley[8] PROGMEM = {
  0b00111100,
  0b01000010,
  0b10100101,
  0b10000001,
  0b10100101,
  0b10011001,
  0b01000010,
  0b00111100
};
// 8×8 のビットマップ(例:アンテナアイコン)
const uint8_t antena[8] PROGMEM = {
  0b11111110,
  0b01010100,
  0b00111000,
  0b00010000,
  0b00010000,
  0b00010000,
  0b00010000,
  0b00000000
};
// 8×8 のビットマップ(例:アンテナアイコン)
const uint8_t antenaNG[8] PROGMEM = {
  0b11111110,
  0b11010110,
  0b00111000,
  0b01010000,
  0b00111000,
  0b01010100,
  0b10010010,
  0b00000000
};
const uint8_t antena0[8] PROGMEM = {
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000
};
const uint8_t antena1[8] PROGMEM = {
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b01000000,
  0b00000000
};
const uint8_t antena2[8] PROGMEM = {
  0b00000000,
  0b00000000,
  0b00000000,
  0b00000000,
  0b00010000,
  0b00010000,
  0b01010000,
  0b00000000
};
const uint8_t antena3[8] PROGMEM = {
  0b00000000,
  0b00000000,
  0b00000100,
  0b00000100,
  0b00010100,
  0b00010100,
  0b01010100,
  0b00000000
};
const uint8_t antena4[8] PROGMEM = {
  0b00000001,
  0b00000001,
  0b00000101,
  0b00000101,
  0b00010101,
  0b00010101,
  0b01010101,
  0b00000000
};

// 8×8 のビットマップ(例:アンテナアイコン)
const uint8_t wifi_ant[8] PROGMEM = {
  0b01111100,
  0b10000010,
  0b00111000,
  0b01000100,
  0b00010000,
  0b00101000,
  0b00010000,
  0b00000000
};

#define OLED_RESET          (-1)
#define SCREEN_ADDRESS      (0x3C)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
U8G2_FOR_ADAFRUIT_GFX u8g2_for_adafruit_gfx;
//for scroll text
ScrollingText scroller1(u8g2_for_adafruit_gfx, SCREEN_WIDTH, 32, u8g2_font_unifont_t_japanese1, -2);
ScrollingText scroller2(u8g2_for_adafruit_gfx, SCREEN_WIDTH, 48, u8g2_font_unifont_t_japanese1, -3);

// Digital I/O used
# define I2S_DOUT      11  // DIN connection
# define I2S_BCLK      13  // Bit clock
# define I2S_LRC       12  // Left Right Clock

Audio audio;

String stations[] ={
        "radio-stream.nhk.jp/hls/live/2023229/nhkradiruakr1/master.m3u8",   //NHK 1
        "radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8",   //NHK 2
        "radio-stream.nhk.jp/hls/live/2023507/nhkradiruakfm/master.m3u8",   //NHK FM
        "stream.laut.fm/animefm",
        "cast1.torontocast.com:2120/;.mp3",
        "cast1.torontocast.com:2170/;.mp3",
//        "cast1.torontocast.com/JapanHits",
//        "cast1.torontocast.com:2120/stream",
        "s3.radio.co/sc8d895604/listen",
        "kathy.torontocast.com:3060/;?shoutcast",
        "vrx.piro.moe:8000/stream-256",
        "vocaloid.radioca.st/stream",                                       //Vocaloid Radio
        "mtist.as.smartstream.ne.jp/30043/livestream/chunklist.m3u8",       //MWave
        "mtist.as.smartstream.ne.jp/30081/livestream/chunklist.m3u8",       //
        "ice1.somafm.com/illstreet-128-mp3",                                // SomaFM / Illinois Street Lounge
        "ais-sa2.cdnstream1.com/b22139_128mp3",                             // 101 SMOOTH JAZZ
        "relax.stream.publicradio.org/relax.mp3",                           // Your Classical - Relax
        "ice1.somafm.com/secretagent-128-mp3",                              // SomaFM / Secret Agent
        "ice1.somafm.com/seventies-128-mp3",                                // SomaFM / Left Coast 70s
        "ice1.somafm.com/bootliquor-128-mp3",                               // SomaFM / Boot Liquor
        "shonanbeachfm.out.airtime.pro:8000/shonanbeachfm_a",               // FM Blue Shonan (FM・ブルー湘南 , JOZZ3AD-FM, 78.5 MHz, Y...
        "0n-80s.radionetz.de:8000/0n-70s.mp3",
        "mediaserv30.live-streams.nl:8000/stream",
        "mp3.ffh.de/radioffh/hqlivestream.aac",                             //  128k aac
        "listen.rusongs.ru/ru-mp3-128",
};

uint8_t cur_station  = 0;         // current station No.

uint8_t num_elements = sizeof(stations) / sizeof(stations[0]);

volatile int encoderCount = 0;
// Encoder control
Rotary encoder = Rotary(ENCODER_PIN_A, ENCODER_PIN_B);
//Wifi
WiFiMulti wifiMulti;

/*
    Reads encoder via interrupt
    Use Rotary.h and  Rotary.cpp implementation to process encoder via interrupt
*/
void rotaryEncoder()
{ // rotary encoder events
  uint8_t encoderStatus = encoder.process();
  if (encoderStatus)
    encoderCount = (encoderStatus == DIR_CW) ? 1 : -1;
}

// callbacks
#if 0
void my_audio_info(Audio::msg_t m) {
    Serial.printf("%s: %s\n", m.s, m.msg);
}
#else
// detailed cb output
void my_audio_info(Audio::msg_t m) {
    switch(m.e){
        case Audio::evt_info:           Serial.printf("info: ....... %s\n", m.msg); break;
        case Audio::evt_eof:            Serial.printf("end of file:  %s\n", m.msg); break;
        case Audio::evt_bitrate:        Serial.printf("bitrate: .... %s\n", m.msg); break; // icy-bitrate or bitrate from metadata
        case Audio::evt_icyurl:         Serial.printf("icy URL: .... %s\n", m.msg); break;
        case Audio::evt_id3data:        Serial.printf("ID3 data: ... %s\n", m.msg); break; // id3-data or metadata
        case Audio::evt_lasthost:       Serial.printf("last URL: ... %s\n", m.msg); break;
        case Audio::evt_name:           Serial.printf("station name: %s\n", m.msg);
          scroller1.setText(m.msg);
          break; // station name or icy-name
        case Audio::evt_streamtitle:    Serial.printf("stream title: %s\n", m.msg);
          scroller2.setText(m.msg);
          break;
        case Audio::evt_icylogo:        Serial.printf("icy logo: ... %s\n", m.msg); break;
        case Audio::evt_icydescription: Serial.printf("icy descr: .. %s\n", m.msg); break;
        case Audio::evt_image: for(int i = 0; i < m.vec.size(); i += 2){
                                        Serial.printf("cover image:  segment %02i, pos %07lu, len %05lu\n", i / 2, m.vec[i], m.vec[i + 1]);} break; // APIC
        case Audio::evt_lyrics:         Serial.printf("sync lyrics:  %s\n", m.msg); break;
        case Audio::evt_log   :         Serial.printf("audio_logs:   %s\n", m.msg); break;
        default:                        Serial.printf("message:..... %s\n", m.msg); break;
    }
}
#endif

void setup() {
  Audio::audio_info_callback = my_audio_info; // optional
 
  rgbLedWrite(RGB_BUILTIN, RGB_BRIGHTNESS, 0, 0);  // Red
  pinMode(ENCODER_PIN_A, INPUT_PULLUP);
  pinMode(ENCODER_PIN_B, INPUT_PULLUP);
  pinMode(PUSH_SW, INPUT_PULLUP);

  Serial.begin(115200);
//  while (!Serial);               // シリアルモニタが開くまで待つ
  delay(10);

  //setup I2C
  Wire.begin(I2C_SDA, I2C_SCL);
  //setup SSD1306
  if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    for(;;);
  }
  u8g2_for_adafruit_gfx.begin(display);                 // connect u8g2 procedures to Adafruit GFX

  display.clearDisplay();

  u8g2_for_adafruit_gfx.setFontDirection(0);            // left to right (this is default)
  u8g2_for_adafruit_gfx.setForegroundColor(WHITE);      // apply Adafruit GFX color
 
  u8g2_for_adafruit_gfx.setFont(u8g2_font_7x13_te);  // icon font
  u8g2_for_adafruit_gfx.setFontMode(1);                 // use u8g2 transparent mode (this is default)
  u8g2_for_adafruit_gfx.setCursor(32,16);                // start writing at this position
  u8g2_for_adafruit_gfx.println(F(" ESP32-S3"));
//  u8g2_for_adafruit_gfx.setFont(u8g2_font_siji_t_6x10);  // icon font
//  u8g2_for_adafruit_gfx.setFontMode(1);                 // use u8g2 transparent mode (this is default)
  u8g2_for_adafruit_gfx.setCursor(0,32);                // start writing at this position
  u8g2_for_adafruit_gfx.println(F("  WebRadio V3.0"));
  u8g2_for_adafruit_gfx.print(F("    2025/8/29"));

  display.display();

  Serial.println("SSD1306 OK");

#if 0
  WiFi.disconnect();
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid.c_str(), password.c_str());
  while (WiFi.status() != WL_CONNECTED) delay(1500);
  Serial.println("WiFi start");
#else
  WiFi.disconnect();
  WiFi.mode(WIFI_STA);
  /*Type all known SSID and their passwords*/
  wifiMulti.addAP("xxxxxxxx", "xxxxxxx");  /*Network 1 we want to connect*/
  wifiMulti.addAP("xxxxxxxx", "xxxxxxx");  /*Network 2 we want to connect*/
  // WiFi.scanNetworks will give total networks
  int n = WiFi.scanNetworks();  /*Scan for available network*/
  Serial.println("scan done");  
  if (n == 0) {
      Serial.println("No Available Networks");  /*Prints if no network found*/
  }
  else {
    Serial.print(n);
    Serial.println(" Networks found");  /*Prints if network found*/
    for (int i = 0; i < n; ++i) {
      Serial.print(i + 1);  /*Print the SSID and RSSI of available network*/
      Serial.print(": ");
      Serial.print(WiFi.SSID(i));
      Serial.print(" (");
      Serial.print(WiFi.RSSI(i));
      Serial.print(")");
      Serial.println((WiFi.encryptionType(i) == WIFI_AUTH_OPEN)?" ":"*");
      delay(10);
    }
  }
 /*Connects to strongest available defined network with SSID and Password available*/
   Serial.println("Connecting to Wifi...");
#if 0
  if(wifiMulti.run() == WL_CONNECTED) {
    Serial.println("");
    Serial.print("Connected to WIFI Network: ");
    Serial.println(WiFi.SSID());
    Serial.print("IP address of Connected Network: ");
    Serial.println(WiFi.localIP());    /*Prints IP address of connected network*/
    rgbLedWrite(RGB_BUILTIN, 0, RGB_BRIGHTNESS, 0);  // Green
  }
#else
  while (WiFi.status() != WL_CONNECTED) {
    wifiMulti.run();
//    delay(1000);
    if (WiFi.status() == WL_CONNECTED) {
      Serial.println("");
      Serial.print("Connected to WIFI Network: ");
      Serial.println(WiFi.SSID());
      Serial.print("IP address of Connected Network: ");
      Serial.println(WiFi.localIP());    /*Prints IP address of connected network*/
      rgbLedWrite(RGB_BUILTIN, 0, RGB_BRIGHTNESS, 0);  // Green
    }
    else {
      Serial.println("Wi-Fi Nothing!\nRetry!!");
    }
  }
#endif
#endif
  //Audio
  audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
  audio.setVolume(12); // 0...21

  rgbLedWrite(RGB_BUILTIN, 0, 0, RGB_BRIGHTNESS);  // Blue
  Serial.println("audio start");
  //set first station
  audio.connecttohost(stations[cur_station].c_str());
  Serial.println(stations[cur_station].c_str());
 
  //display station
  scroller1.setText(F(stations[cur_station].c_str()));

  /* ---------- スクロール設定 ---------- */
  display.setTextSize(2);
  display.setTextColor(WHITE);
  display.setTextWrap(false);

  // Encoder interrupt
  attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), rotaryEncoder, CHANGE);
  attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_B), rotaryEncoder, CHANGE);
}

void loop() {

  static unsigned long lastUpdate = 0;
  const uint32_t UPDATE_MS = 10000;   //
  if (millis() - lastUpdate < UPDATE_MS) {
    ;
  }
  else {
    lastUpdate = millis();
    state = NORMAL;
//    Serial.println(lastUpdate);
  }

  //audio
  audio.loop();
  //display wifi rssi
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi not connected!");
    display.drawBitmap(0, 0, antenaNG, 8, 8, SSD1306_WHITE);
  }
  display.clearDisplay();
#if 1
  display.drawBitmap(0, 0, antena, 8, 8, SSD1306_WHITE);
  int rssi = WiFi.RSSI();
  if (rssi > -55) {
    display.drawBitmap(8, 0, antena4, 8, 8, SSD1306_WHITE);
  }
  else if (rssi > -67){
    display.drawBitmap(8, 0, antena3, 8, 8, SSD1306_WHITE);
  }
  else if (rssi > -70){
    display.drawBitmap(8, 0, antena2, 8, 8, SSD1306_WHITE);
  }
  else if (rssi > -80){
    display.drawBitmap(8, 0, antena1, 8, 8, SSD1306_WHITE);
  }
  else {
    display.drawBitmap(8, 0, antena0, 8, 8, SSD1306_WHITE);
  }
#else
  display.drawBitmap(0, 0, wifi_ant, 8, 8, SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(8, 0);
  display.print(WiFi.RSSI());
  display.print("dBm ");
#endif
 
  //display volume
  display.setTextSize(1);
  display.setCursor(80, 0);
  display.print(audio.getVolume());

  //displsy audio level
  // Convert to bar width (0..32)
  uint8_t barWidth = constrain((uint16_t)(audio.getVUlevel()/1024), 0, SCREEN_WIDTH / 4);
  uint8_t barHeight = SCREEN_HEIGHT / 8 - 2;      //level meter hight
  display.fillRect(VUPOS_X, VUPOS_Y, barWidth, barHeight, WHITE);
  // Optional: draw border
  display.drawRect(VUPOS_X-1, VUPOS_Y-1, 32+1, barHeight+2, WHITE);

  switch (state) {
    case NORMAL:
      // Check if the encoder has moved.
      if (encoderCount != 0)
      {
        if (encoderCount == 1)
        {
          rgbLedWrite(RGB_BUILTIN, 0, RGB_BRIGHTNESS, 0);  // Green
          Serial.println("Encoder up");
          cur_station = (cur_station == num_elements - 1) ? 0 : (++cur_station);
        }
        else
        {
          rgbLedWrite(RGB_BUILTIN, 0, RGB_BRIGHTNESS, 0);  // Green
          Serial.println("Encoder down");
          cur_station = (cur_station == 0) ? (num_elements - 1) : (--cur_station);
        }
        encoderCount = 0;
        audio.connecttohost(stations[cur_station].c_str());
        Serial.println(stations[cur_station].c_str());
        scroller1.setText(F(stations[cur_station].c_str()));
        scroller2.setText("");
        rgbLedWrite(RGB_BUILTIN, 0, 0, RGB_BRIGHTNESS);  // Blue
      }
      else if (digitalRead(PUSH_SW) == LOW) {
        Serial.println("Push");
        vTaskDelay(500);
        state = VOLUME;
        lastUpdate = millis();
      }
 
      //display codec & bitrate
      display.setTextSize(1);
      display.setCursor(0, 8);
      display.print("CH:");
      display.print(cur_station);
      display.print(" ");
      display.print(audio.getCodecname());
      display.print(":");
      display.print(audio.getBitRate(false));
      display.print("bps");
      scroller1.update();   // 1フレーム分を描画
      scroller2.update();   // 1フレーム分を描画

      //display station url
      display.display();
      break;
    case VOLUME:
//      Serial.println("volume");
        int vol = audio.getVolume();
        //display volume control
        display.setTextSize(1);
        display.setCursor(32, 8);
        display.print("Volume");

        display.fillRect(VOLPOS_X, VOLPOS_Y, vol * 4, 8, WHITE);
        // Optional: draw border
        display.drawRect(VOLPOS_X-1, VOLPOS_Y-1, 21 * 4 + 1, 8 + 2, WHITE);

        // Check if the encoder has moved.
        if (encoderCount != 0)
        {
          if (encoderCount == 1)
          {
            Serial.println("Encoder up");
            audio.setVolume((vol < 21) ? ++vol : 21); // 0...21
          }
          else
          {
            Serial.println("Encoder down");
            audio.setVolume((vol > 0) ? --vol : 0); // 0...21
          }
          lastUpdate = millis();
        }
        else if (digitalRead(PUSH_SW) == LOW) {
          Serial.println("Push");
          state = NORMAL;
          vTaskDelay(500);    //チャタリング対策
        }
        encoderCount = 0;
        display.display();
        break;
//    default:
//      break;
  }
  vTaskDelay(1);
}