Skip to content

デバイス連携 ​

第5版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

ブラウザの API を使って、カメラ・NFC・シリアル機器から項目へ直接入力するカスタマイズと、添付した音声ファイルを画面上で再生するカスタマイズをまとめます。いずれも本体の改修は不要です。

レシピ使う技術実装方法主な制約
バーコード/QR コード読み取りhtml5-qrcode(カメラ)拡張 HTML + 拡張スクリプト + 拡張スタイルHTTPS 必須
NFC タグ読み取りWeb NFC API拡張スクリプト + 拡張スタイルChrome for Android 89 以降のみ、HTTPS 必須、NDEF のみ
計量インジケータ連携Web Serial API拡張スクリプト + 拡張スタイルデスクトップ版 Chrome / Edge / Opera 89 以降、HTTPS 必須
サウンドプレーヤーaudio タグサーバスクリプト-

入力系の 3 つは共通して、日付項目の「今」ボタンと同じ date-field の構造(入力欄をラッパーで囲み、右端に Material Symbols のアイコンボタンを置く)を使い、対象項目はコントロール CSS で指定します(テーブルの管理 → エディタ → 対象項目 → 詳細設定 → コントロール CSS)。読み取った値は $p.set で項目にセットします。

バーコード/QR コードを読み取る ​

分類項目の横にスキャンアイコンを置き、クリックするとカメラのプレビューが開いて、読み取った値が分類項目に入ります。ライブラリは html5-qrcode を使い、QR コード・Code 128・Code 39・EAN-13・EAN-8・UPC-A など主要なフォーマットに対応します。

図を読み込み中…

1. ライブラリを読み込む(拡張 HTML) ​

拡張 HTML の HtmlHeaderBottom.html に書きます。

html
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>

CDN にアクセスできないイントラネット環境では、GitHub のリリースページまたは npm から html5-qrcode.min.js を取得して wwwroot/scripts/ に置き、<script src="/scripts/html5-qrcode.min.js"></script> に変えます。スクリプトとスタイルはそのまま使えます。

2. 拡張スクリプト ​

js
$(function () {
  // barcode クラスが付与された分類項目にスキャンボタンを追加
  $('.control-textbox.barcode').each(function () {
    var $input = $(this);
    // date-field と同じ構造:ラッパーで囲んで右端にボタンを配置
    $input.wrap('<div class="barcode-field"></div>');
    var $btn = $('<button>', {
      type: 'button',
      class: 'barcode-scan-trigger',
      title: 'バーコード/QRコードを読み取る'
    }).append(
      $('<span>', { class: 'material-symbols-sharp is-fill', text: 'qr_code_scanner' })
    ).on('click', function (e) {
      e.preventDefault();
      startScan($input.attr('id'));
    });
    $input.after($btn);
  });
});

var scanner = null;
var currentTargetId = null;

// スキャン開始
function startScan(controlId) {
  currentTargetId = controlId;

  // モーダルが未生成なら作成
  if (!$('#barcode-modal').length) {
    $('body').append(
      '<div id="barcode-modal">' +
        '<div class="barcode-overlay"></div>' +
        '<div class="barcode-dialog">' +
          '<div class="barcode-header">' +
            '<span>バーコード/QRコード スキャン</span>' +
            '<button type="button" id="barcode-close" class="barcode-scan-trigger" style="position:static;width:auto;height:auto;">' +
              '<span class="material-symbols-sharp is-fill">close</span>' +
            '</button>' +
          '</div>' +
          '<div id="barcode-reader"></div>' +
          '<p id="barcode-error"></p>' +
        '</div>' +
      '</div>'
    );
    $('#barcode-close, .barcode-overlay').on('click', stopScan);
  }

  $('#barcode-error').text('');
  $('#barcode-modal').show();

  // カメラを起動してスキャン開始
  scanner = new Html5Qrcode('barcode-reader');
  scanner.start(
    { facingMode: 'environment' },
    { fps: 10, qrbox: { width: 250, height: 250 } },
    function (decodedText) {
      // 読み取り成功 → 分類項目に値をセットしてモーダルを閉じる
      $p.set($('#' + currentTargetId), decodedText);
      stopScan();
    },
    function () {
      // 読み取り中(検出できなかったフレームは無視)
    }
  ).catch(function () {
    $('#barcode-error').text('カメラの起動に失敗しました');
  });
}

// スキャン停止
function stopScan() {
  if (scanner) {
    scanner.stop().then(function () {
      scanner.clear();
    }).catch(function () {});
    scanner = null;
  }
  $('#barcode-modal').hide();
  currentTargetId = null;
}
  • 背面カメラ(facingMode: 'environment')を優先します。PC では利用可能なカメラが自動選択されます。
  • アイコンはプリザンターが読み込み済みの Google Material Symbols(qr_code_scanner)で、日付項目の「今」ボタンと同じスタイル設定です。

3. 拡張スタイル(要点) ​

App_Data/Parameters/ExtendedStyles/BarcodeReader.css に、ボタン配置とモーダルの見た目を書きます。全文は抜粋の下に載せます。

css
/* date-field と同じレイアウト:入力欄の右端にボタンを配置 */
.barcode-field {
  position: relative;
  display: block;
}

.barcode-field .control-textbox {
  padding-right: 24px;
}

.barcode-scan-trigger {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  /* …中略… */
  width: 24px;
  height: 100%;
}
ExtendedStyles/BarcodeReader.css
css
/* date-field と同じレイアウト:入力欄の右端にボタンを配置 */
.barcode-field {
  position: relative;
  display: block;
}

.barcode-field .control-textbox {
  padding-right: 24px;
}

.barcode-scan-trigger {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 100%;
  margin: 0;
  padding: 0;
  background: transparent;
  border: none;
  outline: none;
  cursor: pointer;
}

.barcode-scan-trigger .material-symbols-sharp {
  font-size: 16px;
}

#barcode-modal .barcode-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 9998;
}

#barcode-modal .barcode-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  z-index: 9999;
  min-width: 350px;
  max-width: 90vw;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

/* 横長画面:高さ50%を基準にサイズ確保 */
@media (orientation: landscape) {
  #barcode-modal .barcode-dialog {
    width: min(60vw, 640px);
    height: 50vh;
  }
  #barcode-modal #barcode-reader {
    height: calc(50vh - 72px);
  }
}

/* 縦長画面:横幅いっぱいに収める */
@media (orientation: portrait) {
  #barcode-modal .barcode-dialog {
    width: 90vw;
    max-height: 70vh;
  }
  #barcode-modal #barcode-reader {
    height: calc(70vh - 72px);
    max-height: 50vw;
  }
}

#barcode-modal .barcode-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
  font-weight: bold;
}

#barcode-modal #barcode-error {
  margin-top: 8px;
  color: red;
  font-size: 12px;
}

/* html5-qrcode が生成する要素の余白を除去 */
#barcode-reader {
  overflow: hidden;
  border: none !important;
}

#barcode-reader video {
  width: 100% !important;
  height: 100% !important;
  object-fit: cover;
  display: block;
}

#barcode-reader img[alt="end"],
#barcode-reader img[alt="Info"] {
  display: none;
}

モーダルは #barcode-modal の .barcode-overlay(z-index: 9998)と .barcode-dialog(z-index: 9999)で構成し、横長画面では高さ 50vh、縦長画面では横幅 90vw を基準にサイズを切り替えています。

4. 項目を設定する ​

対象の分類項目のコントロール CSS に barcode を入力します。

WARNING

  • 選択肢一覧は空にして、テキスト入力の分類項目として使ってください。ドロップダウンやラジオボタンではスキャンした値を直接入力できません。
  • カメラへのアクセスには HTTPS 接続が必要です(localhost は例外)。HTTP 環境ではカメラが起動しない場合があります。

NFC タグを読み取る ​

Chrome for Android に搭載されている Web NFC API で、スマートフォンをタグにかざすだけで分類項目に値を入れます。光学式ではないので、印字の汚れや暗所の影響を受けません。外部ライブラリを使わないため拡張 HTML は不要です。

制約 ​

項目内容
対応ブラウザChrome for Android 89 以降のみ
非対応デスクトップ版 Chrome / Edge、iOS の Safari・Chrome
接続要件HTTPS 必須(localhost は例外)。社内向けでも証明書を入れて HTTPS 化が必要
呼び出し条件ユーザー操作(クリック等)を起点にする必要がある
読み取れるものNDEF フォーマットのタグのみ
対応タグNFC Forum Type 1〜5(NTAG213 / NTAG215 / NTAG216 など)

FeliCa カードの ID は読めません

交通系 IC カードや社員証などの FeliCa カードは、Web NFC ではカード固有の IDm や内部データを読み取れません。Web NFC が扱えるのは NDEF メッセージだけで、FeliCa の独自コマンドは送れないためです。自分たちで用意した NTAG シールを貼る運用が前提になります。

iOS が非対応なので、同じ項目にバーコードと NFC の両方のボタンを出し、端末に応じて使い分ける運用が現実的です。

バーコードリーダーと併用する場合

このページに載せたバーコードリーダーと NFC リーダーのスクリプトは、どちらもグローバル関数 startScan / stopScan を定義しています。拡張スクリプトとして両方を読み込むと、後から読み込まれた方の関数で上書きされるため、併用するときはどちらかの関数名を変更してください。

図を読み込み中…

拡張スクリプト ​

js
$(function () {
  // Web NFC 非対応ブラウザではボタン自体を出さない
  if (!('NDEFReader' in window)) { return; }

  // nfc / nfc-serial クラスが付与された分類項目にボタンを追加
  $('.control-textbox.nfc, .control-textbox.nfc-serial').each(function () {
    var $input = $(this);
    var useSerial = $input.hasClass('nfc-serial');
    $input.wrap('<div class="nfc-field"></div>');
    var $btn = $('<button>', {
      type: 'button',
      class: 'nfc-scan-trigger',
      title: 'NFCタグを読み取る'
    }).append(
      $('<span>', { class: 'material-symbols-sharp is-fill', text: 'nfc' })
    ).on('click', function (e) {
      e.preventDefault();
      startScan($input.attr('id'), useSerial);
    });
    $input.after($btn);
  });
});

var nfcAbort = null;
var nfcTargetId = null;
var nfcUseSerial = false;

// 読み取り開始
function startScan(controlId, useSerial) {
  nfcTargetId = controlId;
  nfcUseSerial = useSerial;

  // モーダルが未生成なら作成
  if (!$('#nfc-modal').length) {
    $('body').append(
      '<div id="nfc-modal">' +
        '<div class="nfc-overlay"></div>' +
        '<div class="nfc-dialog">' +
          '<div class="nfc-header">' +
            '<span>NFCタグ読み取り</span>' +
            '<button type="button" id="nfc-close" class="nfc-scan-trigger" style="position:static;width:auto;height:auto;">' +
              '<span class="material-symbols-sharp is-fill">close</span>' +
            '</button>' +
          '</div>' +
          '<span class="material-symbols-sharp is-fill nfc-wave">contactless</span>' +
          '<p class="nfc-message">タグに端末をかざしてください</p>' +
          '<p id="nfc-error"></p>' +
        '</div>' +
      '</div>'
    );
    $('#nfc-close, .nfc-overlay').on('click', stopScan);
  }

  $('#nfc-error').text('');
  $('#nfc-modal').show();

  // scan() は AbortController で明示的に止める
  nfcAbort = new AbortController();
  var reader = new NDEFReader();

  reader.scan({ signal: nfcAbort.signal }).then(function () {
    reader.onreadingerror = function () {
      $('#nfc-error').text('タグを読み取れませんでした。もう一度かざしてください');
    };
    reader.onreading = function (event) {
      var value = nfcUseSerial
        ? event.serialNumber
        : readNdefText(event.message);
      if (!value) {
        $('#nfc-error').text('対応するレコードが見つかりませんでした');
        return;
      }
      $p.set($('#' + nfcTargetId), value);
      stopScan();
    };
  }).catch(function (e) {
    // NotAllowedError: 権限拒否 / NotSupportedError: NFCが無効
    $('#nfc-error').text('読み取りを開始できませんでした(' + e.name + ')');
  });
}

// NDEFメッセージから最初の text / url レコードを取り出す
function readNdefText(message) {
  for (var i = 0; i < message.records.length; i++) {
    var record = message.records[i];
    if (record.recordType === 'text') {
      return new TextDecoder(record.encoding || 'utf-8').decode(record.data);
    }
    if (record.recordType === 'url' || record.recordType === 'absolute-url') {
      return new TextDecoder().decode(record.data);
    }
  }
  return null;
}

// 読み取り停止
function stopScan() {
  if (nfcAbort) {
    nfcAbort.abort();
    nfcAbort = null;
  }
  $('#nfc-modal').hide();
  nfcTargetId = null;
}
  • 'NDEFReader' in window で判定し、非対応ブラウザ(iPhone や PC)ではボタンを描画しません。
  • reader.scan() を呼ぶと待受状態になります。映像は出ないので、モーダルの contactless アイコンを明滅させて待受中であることを示します。
  • scan() は自動では止まりません。 AbortController を保持し、モーダルを閉じるときに abort() します。忘れると、閉じたあともタグをかざすたびに onreading が発生します。

拡張スタイル(要点) ​

App_Data/Parameters/ExtendedStyles/NfcReader.css の構成はバーコードリーダーと同じで、クラス名が nfc-field / nfc-scan-trigger / #nfc-modal になります。待受中のアイコンは次のアニメーションで明滅させます。全文は抜粋の下に載せます。

css
/* 待受中のアイコンをゆっくり明滅させる */
#nfc-modal .nfc-wave {
  font-size: 64px;
  color: #4a89dc;
  animation: nfc-pulse 1.6s ease-in-out infinite;
}

@keyframes nfc-pulse {
  0%, 100% { opacity: 0.3; transform: scale(0.92); }
  50%      { opacity: 1;   transform: scale(1); }
}
ExtendedStyles/NfcReader.css
css
/* date-field と同じレイアウト:入力欄の右端にボタンを配置 */
.nfc-field {
  position: relative;
  display: block;
}

.nfc-field .control-textbox {
  padding-right: 24px;
}

.nfc-scan-trigger {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 100%;
  margin: 0;
  padding: 0;
  background: transparent;
  border: none;
  outline: none;
  cursor: pointer;
}

.nfc-scan-trigger .material-symbols-sharp {
  font-size: 16px;
}

#nfc-modal .nfc-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 9998;
}

#nfc-modal .nfc-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: #fff;
  border-radius: 8px;
  padding: 24px;
  z-index: 9999;
  width: min(90vw, 360px);
  text-align: center;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

#nfc-modal .nfc-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  font-weight: bold;
}

/* 待受中のアイコンをゆっくり明滅させる */
#nfc-modal .nfc-wave {
  font-size: 64px;
  color: #4a89dc;
  animation: nfc-pulse 1.6s ease-in-out infinite;
}

@keyframes nfc-pulse {
  0%, 100% { opacity: 0.3; transform: scale(0.92); }
  50%      { opacity: 1;   transform: scale(1); }
}

#nfc-modal .nfc-message {
  margin-top: 12px;
  font-size: 14px;
}

#nfc-modal #nfc-error {
  margin-top: 8px;
  color: red;
  font-size: 12px;
  min-height: 1.2em;
}

項目を設定する ​

対象の分類項目のコントロール CSS に、次のどちらかを入力します。選択肢一覧は空にしてテキスト入力の分類項目として使ってください。

コントロール CSS入力される値
nfcNDEF の text / url レコードの中身
nfc-serialタグのシリアル番号(event.serialNumber)。中身が空のタグでも一意な ID が取れるので、備品台帳のキーに便利

タグへの書き込み ​

NDEFReader は書き込みもできます。現在のレコード ID をタグに書き込む例です。

js
// 現在のレコードIDをNFCタグに書き込む
function writeCurrentId() {
  var writer = new NDEFReader();
  return writer.write({
    records: [
      { recordType: 'text', lang: 'ja', data: $p.id().toString() }
    ]
  }).then(function () {
    $p.setMessage('#Message', JSON.stringify({ Css: 'alert-success', Text: 'タグに書き込みました' }));
  }).catch(function (e) {
    $p.setMessage('#Message', JSON.stringify({ Css: 'alert-error', Text: '書き込みに失敗しました(' + e.name + ')' }));
  });
}

write() は既存の NDEF メッセージを上書きします。実運用では確認ダイアログを挟むことをおすすめします。

$p.setMessage の第 2 引数は JSON 文字列で、関数の中で JSON.parse されます(message.js#L1-L2)。オブジェクトをそのまま渡すと例外になるため、JSON.stringify で文字列にして渡しています。

動作確認環境について

動作確認環境(端末・OS・ブラウザ・タグ・プリザンターのバージョン)は確認されていません。導入前に実機で確認してください。

計量インジケータの値を取り込む(Web Serial API) ​

Chrome / Edge の Web Serial API でブラウザから直接シリアルポートを開き、A&D の計量インジケータ(RS-232C 出力)から安定値を読み取って数値項目に入れます。ゼロ点調整と風袋引きもブラウザから実行できます。常駐アプリやキーボードウェッジは不要で、PC 側に必要なのは USB シリアル変換ケーブルのドライバだけです。

図を読み込み中…

制約 ​

項目内容
対応ブラウザChrome / Edge / Opera のデスクトップ版 89 以降
非対応Android・iOS のすべてのブラウザ、Firefox、Safari
接続要件HTTPS 必須(localhost は例外)
呼び出し条件requestPort() はユーザー操作を起点にする必要がある
権限一度許可すればオリジン単位で保持され、getPorts() で再取得できる

検査台や出荷場に据えた PC での利用が前提です。

A&D のシリアル出力 ​

通信パラメータ: A&D の伝統的な既定値は 2400bps / データ 7bit / 偶数パリティ / ストップ 1bit です。機種によっては 9600bps・8bit・パリティなしが既定だったり、本体メニューで変更できたりするので、必ず実機のメニューか取扱説明書で現在値を確認してください。合っていないと、つながっているのに文字化けした文字列が流れてきます。

出力フォーマット: ヘッダ・符号・数値・単位を連結した固定長テキストを CR+LF 区切りで送ってきます(例: ST,+00123.45 g)。

部分内容
ヘッダ(2 文字)ST 安定 / US 不安定 / OL オーバーロード / QT 個数モード
符号+ または -
数値ゼロ埋めされた数値(小数点を含む)
単位右詰めの単位文字列(g / kg など)

ワークを載せた直後の US(不安定)の値を登録すると測定値がぶれるため、US を拾わないことが肝です。

コマンド(CR+LF 終端):

コマンド動作
S安定してから 1 回だけ出力する
SI安定を待たず即座に 1 回出力する
SIR連続出力を開始する
CSIR の連続出力を止める
T風袋引き(表示をゼロにして風袋値を記憶)
Zゼロ点調整

ここでは S を使い、安定待ちをインジケータ側に任せます。SIR を使う場合は、止め忘れると受信バッファが膨らみ続けるため、必ず C で止める後始末をセットで書いてください。

拡張スクリプト ​

AD_SERIAL_OPTIONS は実機の設定に合わせて変えてください。

js
var adPort = null;
var adOpened = false;

// 通信パラメータ(実機の設定に合わせて変更する)
var AD_SERIAL_OPTIONS = {
  baudRate: 2400,
  dataBits: 7,
  parity: 'even',
  stopBits: 1,
  flowControl: 'none'
};

$(function () {
  if (!('serial' in navigator)) { return; }

  // weigh クラスが付与された数値項目に計量ボタンを追加
  $('.control-textbox.weigh').each(function () {
    var $input = $(this);
    $input.wrap('<div class="weigh-field"></div>');
    var $btn = $('<button>', {
      type: 'button',
      class: 'weigh-trigger',
      title: '計量値を取得する'
    }).append(
      $('<span>', { class: 'material-symbols-sharp is-fill', text: 'scale' })
    ).on('click', function (e) {
      e.preventDefault();
      onWeighClick($btn, $input);
    });
    $input.after($btn);

    // ゼロ・風袋引きのコマンドボタン
    var $commands = $('<span class="weigh-commands"></span>')
      .append($('<button type="button">ゼロ</button>').on('click', function (e) {
        e.preventDefault();
        sendCommand('Z', 'ゼロ点調整');
      }))
      .append($('<button type="button">風袋引き</button>').on('click', function (e) {
        e.preventDefault();
        sendCommand('T', '風袋引き');
      }));
    $input.closest('.field-normal, .field-wide').append($commands);
  });
});

// 計量ボタンのクリック
async function onWeighClick($btn, $input) {
  $btn.addClass('is-busy');
  try {
    var frame = await readStableWeight(10000);
    $p.set($input, frame.value);
    $p.setMessage('#Message', JSON.stringify({
      Css: 'alert-success',
      Text: '計量値を取得しました(' + frame.value + ' ' + frame.unit + ')'
    }));
  } catch (e) {
    $p.setMessage('#Message', JSON.stringify({ Css: 'alert-error', Text: e.message }));
  } finally {
    $btn.removeClass('is-busy');
  }
}

// ポートを開く(2回目以降は選択ダイアログを出さない)
async function ensurePort() {
  if (adPort && adOpened) { return adPort; }

  if (!adPort) {
    var granted = await navigator.serial.getPorts();
    adPort = granted.length ? granted[0] : await navigator.serial.requestPort();
  }
  await adPort.open(AD_SERIAL_OPTIONS);
  adOpened = true;

  // ページを離れるときに閉じる
  window.addEventListener('beforeunload', function () {
    if (adPort && adOpened) { adPort.close(); }
  });
  return adPort;
}

// S コマンドを投げて安定値を1件読む
async function readStableWeight(timeoutMs) {
  var port = await ensurePort();

  // S:安定してから1回だけ出力
  var writer = port.writable.getWriter();
  try {
    await writer.write(new TextEncoder().encode('S\r\n'));
  } finally {
    writer.releaseLock();
  }

  var reader = port.readable.getReader();
  var decoder = new TextDecoder();
  var deadline = Date.now() + timeoutMs;
  var buffer = '';

  try {
    while (true) {
      var remaining = deadline - Date.now();
      if (remaining <= 0) {
        throw new Error('計量値を取得できませんでした(タイムアウト)');
      }

      // read() は行が来るまで待ち続けるので、必ずタイムアウトと競わせる
      var result = await withTimeout(reader.read(), remaining);
      if (result.done) { break; }

      buffer += decoder.decode(result.value, { stream: true });

      var index;
      while ((index = buffer.indexOf('\r\n')) >= 0) {
        var line = buffer.slice(0, index);
        buffer = buffer.slice(index + 2);

        var frame = parseAdFrame(line);
        if (!frame) { continue; }
        if (frame.overload) { throw new Error('オーバーロードです。ワークを降ろしてください'); }
        if (frame.stable) { return frame; }
      }
    }
    throw new Error('計量値を取得できませんでした(切断)');
  } finally {
    // ロックを解放しないと次回 readable を掴めなくなる
    try { await reader.cancel(); } catch (e) { /* noop */ }
    reader.releaseLock();
  }
}

// Z / T などの単発コマンドを送る
async function sendCommand(command, label) {
  try {
    var port = await ensurePort();
    var writer = port.writable.getWriter();
    try {
      await writer.write(new TextEncoder().encode(command + '\r\n'));
    } finally {
      writer.releaseLock();
    }
    $p.setMessage('#Message', JSON.stringify({ Css: 'alert-success', Text: label + 'を実行しました' }));
  } catch (e) {
    $p.setMessage('#Message', JSON.stringify({ Css: 'alert-error', Text: label + 'に失敗しました(' + e.message + ')' }));
  }
}

// "ST,+00123.45  g" を解釈する
function parseAdFrame(line) {
  var matched = /^(ST|US|OL|QT),([+-])\s*([0-9.]+)\s*(\S*)$/.exec(line.trim());
  if (!matched) { return null; }
  return {
    header: matched[1],
    stable: matched[1] === 'ST' || matched[1] === 'QT',
    overload: matched[1] === 'OL',
    value: parseFloat(matched[2] + matched[3]),
    unit: matched[4]
  };
}

// Promise にタイムアウトを付ける
function withTimeout(promise, ms) {
  var timer;
  return Promise.race([
    promise.finally(function () { clearTimeout(timer); }),
    new Promise(function (resolve, reject) {
      timer = setTimeout(function () { reject(new Error('タイムアウト')); }, ms);
    })
  ]);
}
  • 'serial' in navigator で判定し、非対応ブラウザ(タブレット等)ではボタンを出しません。
  • getPorts() で許可済みのポートがあればダイアログを出さずに接続し、初回だけ requestPort() で選ばせます。
  • S で安定待ちをインジケータに任せつつ、US が混ざって届くことがあるので受信側でも stable を判定します。
  • ゼロ点調整・風袋引きは Z / T を送るだけです。
  • メッセージ表示の $p.setMessage には、NFC の書き込み例と同じく JSON.stringify した文字列を渡しています。

拡張スタイル(要点) ​

App_Data/Parameters/ExtendedStyles/Weighing.css の構成はバーコードリーダーと同じ date-field パターン(.weigh-field / .weigh-trigger)です。計量中は .is-busy でアイコンを回転させ、ゼロ・風袋引きボタンは .weigh-commands で並べます。全文は抜粋の下に載せます。

css
/* 計量中はアイコンを回す */
.weigh-trigger.is-busy .material-symbols-sharp {
  animation: weigh-spin 1s linear infinite;
}

@keyframes weigh-spin {
  to { transform: rotate(360deg); }
}
ExtendedStyles/Weighing.css
css
/* date-field と同じレイアウト:入力欄の右端にボタンを配置 */
.weigh-field {
  position: relative;
  display: block;
}

.weigh-field .control-textbox {
  padding-right: 24px;
}

.weigh-trigger {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 100%;
  margin: 0;
  padding: 0;
  background: transparent;
  border: none;
  outline: none;
  cursor: pointer;
}

.weigh-trigger .material-symbols-sharp {
  font-size: 16px;
}

/* 計量中はアイコンを回す */
.weigh-trigger.is-busy .material-symbols-sharp {
  animation: weigh-spin 1s linear infinite;
}

@keyframes weigh-spin {
  to { transform: rotate(360deg); }
}

/* ゼロ・風袋引きボタン */
.weigh-commands {
  display: inline-flex;
  gap: 4px;
  margin-left: 8px;
}

.weigh-commands button {
  padding: 2px 10px;
  font-size: 12px;
  cursor: pointer;
}

項目を設定する ​

対象の数値項目のコントロール CSS に weigh を入力します。小数点以下の桁数はインジケータの分解能に合わせておきます(ずれていると取り込んだ値が丸められます)。

ハマりやすいところ ​

症状原因と対処
つながっているのに文字化けする通信パラメータの不一致。特に 7bit / 偶数パリティを 8bit / パリティなしで開くとゴミが流れる。AD_SERIAL_OPTIONS をインジケータの設定に合わせる
2 回目の計量でエラーになるreader.releaseLock() の呼び忘れ。port.readable は同時に 1 つの reader しか持てないので、例外経路も含めて finally で解放する
ボタンが回りっぱなしになるreader.read() にタイムアウトがない。ケーブル抜け・電源オフ・S に応答しない設定のいずれでも read() は返らないので、withTimeout() で必ず競わせる
単位が混ざるg と kg は機種設定で切り替わる。項目の単位を固定するなら、parseAdFrame() の unit を見て換算するか、想定外の単位ならエラーにする

動作確認環境について

実機での動作確認環境(インジケータの機種・通信パラメータ・変換ケーブル・ブラウザ・プリザンターのバージョン)は確認されていません。実機の出力フォーマットが想定と違う場合は、parseAdFrame() の正規表現を修正してください。導入前に実機で確認してください。

添付した音声ファイルを再生する(サウンドプレーヤー) ​

添付ファイル項目の音声ファイルを、一覧画面上でそのまま再生できるように HTML の <audio> タグを埋め込みます。サーバスクリプトを条件「行表示の前」で登録します。

js
const attachments = JSON.parse(model.AttachmentsA || '[]');

let html = '';
attachments.forEach(function(attachment){
    html += `<audio controls preload="none"><source src="/binaries/${attachment.Guid}/download" type="audio/wav"></audio>`;
});
if (html) {
    columns.AttachmentsA.RawText = html;
}
  • model.AttachmentsA の JSON をパースし、ファイルごとのプレーヤーを連結した HTML を columns.AttachmentsA.RawText に設定します。未設定の RawText は null なので(ServerScriptModel.cs#L226)、RawText += ... で直接連結すると先頭に null が付くおそれがあります。そのため、文字列を組み立ててから代入しています。
  • ファイルの URL は /binaries/{GUID}/download です(BinariesController.cs#L151-L152)。ベースパスのある環境では先頭にベースパスを付けてください。
  • 添付ファイル項目に音声ファイルだけが入っている前提で、例外処理はありません。必要なら attachment.Name の拡張子で判定してください。
  • preload="none" でサーバーからの通信量を抑えているため、再生前は総再生時間などが表示されません。<audio> タグのオプションで調整できます。
  • IP-PBX の録音データなどをプリザンターで管理するときに便利です。

関連ページ ​

変更履歴

第5版本文から元記事や以前の版への言及を除き、正しい動作だけを書く形に整理
第4版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第3版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「画面カスタマイズ集」セクションの記事を追加