0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

M5 Paper から bluetooth プリンタへ画面出力 その2

0
Last updated at Posted at 2026-04-19

前置き

以前、M5Paper から Bluetooth プリンタに画面のハードコピーを出力する最小限のプログラムを作りました。

ここでは AI に、SD Card 内の画像ファイルをブラウズして、気に入ったものをプリントできるようにしてもらいました。プリンタとしては Phomemo 社の 110mm 幅のサーマルプリンタ M04S を対象としました。このプリンタは、基本的に M02S と同じ命令でプリントできます。ディザリングによって階調を近似し、画像をプリンタ幅に拡大して出力しています。

M5Paper と Phomemo M04S 用プログラム

M5Paper は Classical Bluetooth をサポートしているので、素朴にシリアルポート用にデータの垂れ流しで出力できます。後継機の M5PaperS3 は Bluetooth LE にしか対応していないので困難があるようです。

Phomemo M04S は M02S と同じく ESC/POS の画像出力だけに対応していて、ほぼ M02S と命令互換ですが、初期化命令に独自のものが加わっていて、これを実行しないと応答がなくなったりします。

出力結果

nekos.jpg

プログラムは、ジョグダイヤル風のスイッチを上下に動かすことで、SD Card 上にある画像ファイルが順にj表示されます。ダイヤルを押し込むことで表示されている絵がプリンタに出力されます。

プログラム

ディザリングは、AI が 4x4の Mayer 法を提案してくいました。局所的に解決するので計算量も小さく、ESP32 のような CPU 向きです。また 16 階調で表示された画面の情報を利用すること、サーマルプリンタの出力ムラが大きいことなどを考えると、ディザリングに凝る理由も無いように思います。またディザリングと組み合わせることで非整数倍の拡大も単純に行えました。

開発環境としては Arudino を用いましたが、ライブラリの関係から古い M5Stack の Ver.2.1.4 の環境を使う必要があります。

画像データ送信後に、電池の残量を聞いているのは、これが Blocking 命令なので、絵を出力し終わるのを待って Bluetooth 接続を切るためです。

#include <M5EPD.h>
#include "BluetoothSerial.h"
#include "SD.h"

BluetoothSerial SerialBT;

static const int SCREEN_W = 540;
static const int SCREEN_H = 960;

static const int IMAGE_W = SCREEN_W;
static const int IMAGE_H = 930;

static const int PRINT_W = SCREEN_W;
static const int PRINT_H = IMAGE_H;

static const int MAX_FILES = 255;

//uint8_t printer_addr[6] = {0x00, 0x15, 0x83, 0x54, 0xA2, 0x7F}; //M02S
uint8_t printer_addr[6] = {0x00, 0x15, 0x83, 0x8E, 0x55, 0xDF}; //M04S

M5EPD_Canvas imageCanvas(&M5.EPD);
M5EPD_Canvas uiCanvas(&M5.EPD);

String files[MAX_FILES];
int file_count = 0;
int file_index = 0;

bool has_image_ext(const String& name) {
  String lower = name;
  lower.toLowerCase();
  return lower.endsWith(".png") || lower.endsWith(".jpg") ||
         lower.endsWith(".jpeg") || lower.endsWith(".bmp");
}

void scan_image_files_root() {
  file_count = 0;

  File dir = SD.open("/");
  if (!dir) return;

  while (true) {
    File entry = dir.openNextFile();
    if (!entry) break;

    if (!entry.isDirectory()) {
      String name = String(entry.name());
      if (has_image_ext(name)) {
        if (file_count < MAX_FILES) {
          if (!name.startsWith("/")) {
            name = "/" + name;
          }
          files[file_count] = name;
          file_count++;
        }
      }
    }
    entry.close();
  }
  dir.close();
}

void printer_init() {
  const uint8_t INIT_M04S[4] = {0x1f, 0x11, 0x35, 0x00};
  SerialBT.write(INIT_M04S, 4); 
  delay(2);
  const uint8_t INIT_ALL[2] = {0x1b, 0x40};
  SerialBT.write(INIT_ALL , 2); 
  delay(2);
  SerialBT.write(0x1f);
  SerialBT.write(0x11);
  SerialBT.write(0x02); 
  SerialBT.write(0x01); // cencentration 1,3,4
  delay(2);
}

void printer_feed(int n) {
  SerialBT.write(0x1b);
  SerialBT.write(0x64);
  SerialBT.write(n);
}

bool printer_connect() {
  if (!SerialBT.begin("M5Paper_test", true)) return false;
  if (!SerialBT.connect(printer_addr)) return false;
  return true;
}

void draw_ui(const String& msg) {
  uiCanvas.fillCanvas(0);
  uiCanvas.setTextColor(15);
  uiCanvas.setTextSize(2);

  if (file_count > 0) {
    uiCanvas.drawString(String(file_index + 1) + "/" + String(file_count), 20, 6);
    uiCanvas.drawString(files[file_index], 100, 6);
  } else {
    uiCanvas.drawString("0/0", 20, 6);
    uiCanvas.drawString("No image files", 100, 6);
  }

  uiCanvas.drawString(msg, 360, 6);
  uiCanvas.pushCanvas(0, IMAGE_H, UPDATE_MODE_DU4);
}

bool load_current_image() {
  imageCanvas.fillCanvas(0);

  if (file_count <= 0) {
    imageCanvas.setTextSize(3);
    imageCanvas.setTextColor(15);
    imageCanvas.drawString("No image files", 120, 200);
    imageCanvas.pushCanvas(0, 0, UPDATE_MODE_GC16);
    draw_ui("Put png/jpg/bmp in SD root");
    return false;
  }

  String path = files[file_index];
  String suffix = path;
  suffix.toLowerCase();

  bool ok = false;

  if (suffix.endsWith(".png")) {
    ok = imageCanvas.drawPngFile(SD, path.c_str());
  } else if (suffix.endsWith(".jpg") || suffix.endsWith(".jpeg")) {
    ok = imageCanvas.drawJpgFile(SD, path.c_str());
  } else if (suffix.endsWith(".bmp")) {
    ok = imageCanvas.drawBmpFile(SD, path.c_str(), 0, 0);
  }

  if (!ok) {
    imageCanvas.fillCanvas(0);
    imageCanvas.setTextSize(3);
    imageCanvas.setTextColor(15);
    imageCanvas.drawString("File open error", 100, 200);
    imageCanvas.drawString(path, 20, 260);
  }

  imageCanvas.pushCanvas(0, 0, UPDATE_MODE_GC16);
  draw_ui("Btn:L/R Print");

  return ok;
}

void print_canvas(M5EPD_Canvas& cv, int w, int h) { //M04S 1280/912/576 dots 
  static const uint8_t bayer4[4][4] = {
    {  0, 128,  32, 160 },
    {192,  64, 224,  96 },
    { 48, 176,  16, 144 },
    {240, 112, 208,  80 }
  };

  int w2x = 1280; 
  int h2x = h * w2x / w;
  int w8 = (w2x / 8) * 8;
  int wb = w8 / 8;

  if (!printer_connect()) {
    draw_ui("Printer connect failed");
    return;
  }

  printer_init();

  SerialBT.write(0x1d);
  SerialBT.write(0x76);
  SerialBT.write(0x30);
  SerialBT.write(0x00);

  SerialBT.write(wb & 0xff);
  SerialBT.write((wb >> 8) & 0xff);
  SerialBT.write(h2x & 0xff);
  SerialBT.write((h2x >> 8) & 0xff);

  uint8_t linebuf[162];

  for (int y = 0; y < h2x; y++) {
    for (int xb = 0; xb < wb; xb++) {
      uint8_t m = 0;
      int x0 = xb * 8;

      for (int k = 0; k < 8; k++) {
        int xx = x0 + k;
        int sx = xx * w / w2x;
        int sy = y  * w / w2x;
        int p  = cv.readPixel(sx, sy);   // 0..15 想定
        int g  = p * 17;                 // 0..255
        int th = bayer4[y & 3][xx & 3];

        m <<= 1;
        if (g > th) m |= 1;
      }
      linebuf[xb] = m;
    }
    SerialBT.write(linebuf, wb);
    delay(10); // important
  }
  
  const uint8_t ENGY_CMD[3] = {0x1F, 0x11, 0x08};
  SerialBT.setTimeout(3000);
  delay(300);
  SerialBT.write(ENGY_CMD, 3);

  uint8_t resp[3];
  SerialBT.readBytes(resp, 3);

//  delay(200);
//  printer_feed(1);

  delay(200);
  printer_feed(3);
  delay(500);
  SerialBT.disconnect();

  draw_ui("Printed");
}

void setup() {
  M5.begin();
  M5.EPD.SetRotation(90);
  M5.EPD.Clear(true);

  imageCanvas.createCanvas(IMAGE_W, IMAGE_H);
  uiCanvas.createCanvas(SCREEN_W, SCREEN_H - IMAGE_H);

  scan_image_files_root();
  load_current_image();
}

void loop() {
  M5.update();

  if (file_count > 0) {
    if (M5.BtnL.wasPressed()) {
      file_index--;
      if (file_index < 0) file_index = file_count - 1;
      load_current_image();
    }

    if (M5.BtnR.wasPressed()) {
      file_index++;
      if (file_index >= file_count) file_index = 0;
      load_current_image();
    }

    if (M5.BtnP.wasPressed()) {
      draw_ui("Printing...");
      print_canvas(imageCanvas, PRINT_W, PRINT_H);
    }
  }

  delay(20);
}

まとめ

AI を用いると、環境のセットアップも含めて、お任せでゆけるので、はなはだ有難いです。アイデアがあれば実装は AI に任せられる気がしました。

最小限度の機能から徐々に膨らませるようなアプローチの方が、一度にたくさんの機能を実装させるより、早くゴールに辿り着ける印象を受けました。一つの AI で行き詰まったら、別の AI に批評させると、するっと解決する場合もありました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?