WEBアプリ はじめの一歩
これまで「WEBアプリでRFIDシステム はじめの一歩」ということで、3件の記事で紹介させていただきました。
検知したデータを業務に活用するベースとしてCSVファイルを行い、それを確認するためのビューワを紹介してきましたが、わかりやすく散布図でRSSI値を表現したものを紹介します。読み取り状況の確認などで活用できます。
リーダライタからのデータ送信されてきたデータを受信するIISのスクリプトは「WEBアプリでRFIDシステム はじめの一歩」のほうをご覧ください。
IISはWindows11にも搭載されており、Windows10でも11でも基本的に同じように使えます。
RFIDシステムは、導入時にうまく読み取りできるか、ICタグやアンテナの向きなど、数日間設置してみてテストすることも多いです。そんなとき手軽に読み取りの確認や、RSSI値の平均や、アンテナ毎の検知回数を確認するといった用途にも活用できるでしょう。
今回のシステム想定例
今回の想定システムイメージです。

リーダライタ設定
MRU-F7100JP, MRU-F5100JPやFRU-F4025Plus等のリーダライタ側の設定です。詳しくは各々の製品のマニュアルを参照してください。
送信先設定

項目 | 内容 |
---|---|
プロトコル | http |
メソッド | post |
IPアドレス/ホスト名 | 192.168.209.100 |
パス名 | /test/test.asp |
フォーマット | query |
ポート番号 | 80 |
Keep-Alive | 有効 |
連続モード設定

今回は、RSSIの推移をみたいので、重複チェック機能は無効のままでよいでしょう。ただデータが大量に送信されてきますので、ファイルサイズが大きくなります。大きくなりすぎると、ブラウザが動かなくなりますので注意してください。
動作モード変更(読み取りスタート)

動作モードを「連続モード」に変更します。
動作モードを「連続モード」に変更すると、リーダライタはICタグの検知をはじめます。ICタグを検知するとそのデータを、上の送信先設定で設定したWEBサーバ(192.168.209.100)に送信します。
その他、細かいアンテナ設定や送信データ設定などは割愛します。
検知結果の確認
動作モードを連続モードに変更すると、ICタグの読み取りと、検知したICタグデータの送信が行われ、C:\inetpub\wwwroot\test ディレクトリに、csvファイルが生成されます。
>>データを受信するスクリプト@「test.asp」は前の「WEBアプリでRFIDシステム はじめの一歩」をご覧ください。
今回は日付をファイル名にするようなスクリプトですので、2023年5月1日であればdata20230501.csv といった名称のcsvファイルが生成され、同じ日であれば、最後にデータは追加されていきます。
EXCELやメモ帳
csvファイルですので、マイクロソフトのEXCELや、Windowsのメモ帳アプリで内容を確認できます。
生成されるcsvファイル中のデータ例 ファイルネーム例: data20230501.csv
※CSVの内容は1番目の項目が、サーバで付加した日付で、2番目の項目の「TAG」以降がリーダライタから送信されてきたデータです。データの内容・意味につきましてはリーダライタのマニュアルを参照してください。
2023/05/01 17:00:54,TAG,1,20180101090003660,3000,1,-516,E2806894000050028A4304AF,1
2023/05/01 17:00:54,TAG,1,20180101090003714,3000,1,-510,E2806894000040029142A520,1
2023/05/01 17:00:54,TAG,1,20180101090003769,3000,1,-516,E2806894000050028A4304AF,1
参考:CSVファイルを集計・散布図を閲覧をするWEBページ
取得されたデータの活用は、実運用時にはいわゆる業務システムの部分ですが、以下、簡単に確認していただくためにcsvファイルにたまったデータを集計して表示するWEBページ例を示します。
例:
1.以下のファイルの内容を「test7.html」というファイルで保存(UTF-8)
※test.html以外に、「test.asp」(⇒その1ページ)と「clear2log.asp」(⇒その3ページ)も必要です。
2.csvファイルと同じディレクトリ、今回の例であれば、c:\inetpub\wwwroot\test ディレクトリにおく。※「test7.html」「clear2log.asp」「test.asp」の3つのファイルが必要です。
3.ブラウザのアドレス欄に http://localhost/test/test7.html あるいは http://192.168.209.100/test/test7.html と入力して開く。
4.対象csvファイル名欄に、例:data20230515.csv といった対象ファイル名を入力して、[表示開始]をクリック。
これで1秒おきに画面が更新され、csvファイルにたまったデータを集計したアンテナ毎の検知数やRSSI値、PC、EPC等が集計表示されます。
5. [グラフ表示開始]をクリックすると、散布図が表示され、集計表の更新に合わせて、グラフも更新されます。
test7.html のソースコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>CSVデータViewer7</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: "BIZ UDゴシック", sans-serif;
margin: 20px; /* 全体の余白 */
}
table {
border-collapse: collapse;
width: 100%; /* テーブルの幅を調整 */
margin-bottom: 20px; /* テーブルとグラフの間に余白 */
}
th, td {
border: 1px solid black;
padding: 5px;
text-align: left;
}
tr:nth-child(even) {
background-color: #f2f2f2;
}
p#uniqueProducts {
font-size : 2em;
}
.sort-button {
margin-left: 5px;
font-size: 0.8em;
padding: 2px 5px;
}
/* グラフコンテナのスタイルを追加 */
.chart-container {
width: 90%; /* グラフの幅を調整 */
max-width: 1500px; /* 最大幅を設定 */
margin: 20px auto; /* 中央揃えと上下の余白 */
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
/* グラフの縦の長さを指定 */
height: 800px;
position: relative; /* canvasのサイズ調整のために必要 */
}
/* canvasが親コンテナの100%になるように調整 */
.chart-container canvas {
width: 100% !important;
height: 100% !important;
}
</style>
</head>
<body>
<h1>CSVデータViewer7</h1>
対象CSVファイル:<input type=text id="csvFile" value="">
<button id="startButton">表示開始</button>
<button id="stopButton" style="display : none;">表示停止</button>
<button id="resetButton">ソート順リセット</button>
<button id="saveButton">テーブル保存</button>
<button id="clearDispButton">画面表示クリア</button>
<button id="clearButton">CSVファイルクリア to logファイル</button>
<button id="graphButton">グラフ表示開始</button>
<p id="message"></p>
<p id="uniqueProducts"></p>
<table id="dataTable">
<thead>
<tr>
<th>削除</th>
<th>PC<button class="sort-button" data-column="1" data-order="asc">▲</button><button class="sort-button" data-column="1" data-order="desc">▼</button></th>
<th>EPC<button class="sort-button" data-column="2" data-order="asc">▲</button><button class="sort-button" data-column="2" data-order="desc">▼</button></th>
<th>ant0<button class="sort-button" data-column="3" data-order="asc">▲</button><button class="sort-button" data-column="3" data-order="desc">▼</button></th>
<th>ant1<button class="sort-button" data-column="4" data-order="asc">▲</button><button class="sort-button" data-column="4" data-order="desc">▼</button></th>
<th>ant2<button class="sort-button" data-column="5" data-order="asc">▲</button><button class="sort-button" data-column="5" data-order="desc">▼</button></th>
<th>ant3<button class="sort-button" data-column="6" data-order="asc">▲</button><button class="sort-button" data-column="6" data-order="desc">▼</button></th>
<th>全アンテナ合計<button class="sort-button" data-column="7" data-order="asc">▲</button><button class="sort-button" data-column="7" data-order="desc">▼</button></th>
<th>RSSI最小値<button class="sort-button" data-column="8" data-order="asc">▲</button><button class="sort-button" data-column="8" data-order="desc">▼</button></th>
<th>RSSI最大値<button class="sort-button" data-column="9" data-order="asc">▲</button><button class="sort-button" data-column="9" data-order="desc">▼</button></th>
<th>RSSI平均値<button class="sort-button" data-column="10" data-order="asc">▲</button><button class="sort-button" data-column="10" data-order="desc">▼</button></th>
<th>初回検知時刻<button class="sort-button" data-column="11" data-order="asc">▲</button><button class="sort-button" data-column="11" data-order="desc">▼</button></th>
<th>最新検知アンテナ<button class="sort-button" data-column="12" data-order="asc">▲</button><button class="sort-button" data-column="12" data-order="desc">▼</button></th>
<th>最新RSSI<button class="sort-button" data-column="13" data-order="asc">▲</button><button class="sort-button" data-column="13" data-order="desc">▼</button></th>
<th>最新検知時刻<button class="sort-button" data-column="14" data-order="asc">▲</button><button class="sort-button" data-column="14" data-order="desc">▼</button></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class="chart-container">
<canvas id="myScatterChart"></canvas>
</div>
<script>
const startButton = document.getElementById('startButton');
const stopButton = document.getElementById('stopButton');
const resetButton = document.getElementById('resetButton');
const saveButton = document.getElementById('saveButton');
const clearButton = document.getElementById('clearButton');
const clearDispButton = document.getElementById('clearDispButton');
const graphButton = document.getElementById('graphButton');
const dataTable = document.getElementById('dataTable');
const message = document.getElementById('message');
const uniqueProducts = document.getElementById('uniqueProducts');
let intervalId;
let csvFile = "";
let currentSortColumn = -1;
let currentSortOrder = '';
let originalTableRows = [];
let myChart = null;
let graphUpdateIntervalId = null; // グラフ更新用のインターバルID
// CSVの4番目の要素 (yyyymmddHHMMSSミリ秒) をJavaScriptのDateオブジェクトに変換する関数
function parseCSVDateToUnix(dateStr) {
if (dateStr.length < 17) {
console.warn("Invalid date string length for parsing:", dateStr);
return NaN;
}
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1;
const day = parseInt(dateStr.substring(6, 8));
const hour = parseInt(dateStr.substring(8, 10));
const minute = parseInt(dateStr.substring(10, 12));
const second = parseInt(dateStr.substring(12, 14));
const millisecond = parseInt(dateStr.substring(14, 17));
return new Date(year, month, day, hour, minute, second, millisecond).getTime();
}
// グラフを更新する関数
function updateChart(rawData) {
if (!myChart) {
console.log("Chart not yet initialized. Skipping chart update.");
return;
}
const chartData = {};
rawData.forEach(row => {
const columns = row.split(',');
if (columns.length < 8) {
console.warn("Skipping malformed CSV row for chart (too few columns):", row);
return;
}
const timestamp = parseCSVDateToUnix(columns[3]);
const rssi = parseFloat(columns[6]);
const epc = columns[7];
if (isNaN(timestamp) || isNaN(rssi)) {
console.warn('Invalid data for chart (timestamp or RSSI is NaN):', { timestamp, rssi, row });
return;
}
if (!chartData[epc]) {
chartData[epc] = [];
}
chartData[epc].push({ x: timestamp, y: rssi });
});
const newDatasets = [];
const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#C9CBCE'];
let colorIndex = 0;
for (const epc in chartData) {
newDatasets.push({
label: epc,
data: chartData[epc],
borderColor: colors[colorIndex % colors.length],
backgroundColor: colors[colorIndex % colors.length] + '40',
pointRadius: 3,
pointHoverRadius: 5,
showLine: false,
fill: false,
});
colorIndex++;
}
myChart.data.datasets = newDatasets;
myChart.update();
}
// CSVファイルを読み込みテーブル表示 (グラフ更新も含む)
function fetchData() {
fetch(csvFile, { cache: 'no-store' })
.then(response => {
if (!response.ok) {
message.textContent = "対象CSVファイルが読み取りできません: " + new Date().toLocaleString();
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
data = data.trim();
const rawRows = data.split('\n').filter(row => row.trim() !== '');
// グラフが初期化されていれば更新
if (myChart && graphUpdateIntervalId) { // graphUpdateIntervalIdが設定されている時のみ(グラフ更新中)
updateChart(rawRows);
}
const dataMap = {};
if( rawRows.length > 0 ) {
rawRows.forEach(row => {
const columns = row.split(',');
const epc = columns[7];
const antno = parseInt(columns[8]);
const rssi = parseFloat(columns[6]);
if (!dataMap[epc]) {
dataMap[epc] = {
pc: "",
counts: { 0: 0, 1: 0, 2: 0, 3: 0 },
min: Infinity,
max: -Infinity,
sum: 0,
count: 0,
start: columns[0],
recentant: 0,
recentrssi: 0,
end: columns[0]
};
}
dataMap[epc].pc = columns[4];
dataMap[epc].counts[antno]++;
dataMap[epc].min = Math.min(dataMap[epc].min, rssi);
dataMap[epc].max = Math.max(dataMap[epc].max, rssi);
dataMap[epc].sum += rssi;
dataMap[epc].count++;
dataMap[epc].recentantno = antno;
dataMap[epc].recentrssi = rssi;
dataMap[epc].end = columns[0];
});
const tableBody = dataTable.querySelector('tbody');
tableBody.innerHTML = '';
const newRows = [];
Object.entries(dataMap).forEach(([epc, data]) => {
const row = document.createElement('tr');
row.insertCell().innerHTML = '<button onclick="deleteRow(this)">削除</button>';
row.insertCell().textContent = data.pc;
row.insertCell().textContent = epc;
for (const antno of ['0', '1', '2', '3']) {
row.insertCell().textContent = data.counts[antno];
}
row.insertCell().textContent = Object.values(data.counts).reduce((a, b) => a + b, 0);
row.insertCell().textContent = data.min;
row.insertCell().textContent = data.max;
row.insertCell().textContent = (data.count > 0 ? data.sum / data.count : 0).toFixed(2);
row.insertCell().textContent = data.start;
row.insertCell().textContent = data.recentantno;
row.insertCell().textContent = data.recentrssi;
row.insertCell().textContent = data.end;
newRows.push(row);
});
originalTableRows = [...newRows];
if (currentSortColumn !== -1) {
sortAndDisplayTable(currentSortColumn, currentSortOrder, newRows);
} else {
newRows.forEach(row => tableBody.appendChild(row));
}
} else {
const tableBody = dataTable.querySelector('tbody');
tableBody.innerHTML = '';
originalTableRows = [];
}
message.textContent = "更新時刻: " + new Date().toLocaleString();
uniqueProducts.textContent = `ユニークなtag数: ${Object.keys(dataMap).length}`;
})
.catch(error => {
console.error('エラー:', error);
});
}
// テーブルをソートして表示する関数
function sortAndDisplayTable(columnIndex, order, rowsToSort) {
const tableBody = dataTable.querySelector('tbody');
const rows = rowsToSort || Array.from(tableBody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aValue = a.cells[columnIndex].textContent;
const bValue = b.cells[columnIndex].textContent;
const numA = Number(aValue);
const numB = Number(bValue);
if (!isNaN(numA) && !isNaN(numB)) {
return order === 'asc' ? numA - numB : numB - numA;
} else {
return order === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
});
tableBody.innerHTML = '';
rows.forEach(row => tableBody.appendChild(row));
currentSortColumn = columnIndex;
currentSortOrder = order;
}
// グラフ表示/停止ボタンのイベントリスナー
graphButton.addEventListener('click', () => {
if (graphUpdateIntervalId) {
// グラフ更新中なので停止
clearInterval(graphUpdateIntervalId);
graphUpdateIntervalId = null;
graphButton.textContent = 'グラフ表示開始';
message.textContent = "グラフ更新停止: " + new Date().toLocaleString();
} else {
// グラフ更新停止中なので開始
const ctx = document.getElementById('myScatterChart').getContext('2d');
// 既存のチャートがあれば破棄して再作成
if (myChart) {
myChart.destroy();
}
myChart = new Chart(ctx, {
type: 'scatter',
data: {
datasets: []
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
title: {
display: true,
text: 'RSSI vs. Time (リアルタイム)'
},
tooltip: {
callbacks: {
title: function(context) {
const date = new Date(context[0].parsed.x);
return date.toLocaleString('ja-JP', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
fractionalSecondDigits: 3
});
},
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
label += `RSSI: ${context.parsed.y}`;
return label;
}
}
}
},
scales: {
x: {
type: 'linear',
title: {
display: true,
text: '時刻 (Unix Time in ms)'
},
ticks: {
callback: function(value, index, ticks) {
const date = new Date(value);
return date.toLocaleTimeString('ja-JP', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
fractionalSecondDigits: 3
});
}
}
},
y: {
title: {
display: true,
text: 'RSSI値'
}
}
}
}
});
// グラフ更新を開始し、インターバルIDを保存
graphUpdateIntervalId = setInterval(fetchData, 1000); // 1秒ごとに更新
graphButton.textContent = 'グラフ表示停止';
message.textContent = "グラフ更新開始: " + new Date().toLocaleString();
// 開始時に一度データをフェッチして初期表示
fetchData();
}
});
// テーブル表示/停止ボタンのイベントリスナー (既存)
startButton.addEventListener('click', () => {
csvFile = document.getElementById('csvFile').value;
fetchData();
intervalId = setInterval(fetchData, 1000);
startButton.style.display = 'none';
stopButton.style.display = 'inline';
});
stopButton.addEventListener('click', () => {
clearInterval(intervalId);
startButton.style.display = 'inline';
stopButton.style.display = 'none';
});
// その他のボタンイベントリスナー (既存)
resetButton.addEventListener('click', () => {
clearInterval(intervalId);
// グラフ更新も停止
clearInterval(graphUpdateIntervalId);
graphUpdateIntervalId = null;
graphButton.textContent = 'グラフ表示開始';
startButton.style.display = 'inline';
stopButton.style.display = 'none';
currentSortColumn = -1;
currentSortOrder = '';
const tableBody = dataTable.querySelector('tbody');
tableBody.innerHTML = '';
originalTableRows.forEach(row => tableBody.appendChild(row.cloneNode(true)));
if (myChart) {
myChart.data.datasets = [];
myChart.update();
}
});
clearButton.addEventListener('click', () => {
fetch('clear2log.asp?csvFile=' + csvFile)
.then(response => {
if (!response.ok) {
throw new Error('log追加処理に失敗しました');
}
const tableBody = dataTable.querySelector('tbody');
tableBody.innerHTML = '';
if (myChart) {
myChart.data.datasets = [];
myChart.update();
}
console.log('logファイル追加処理完了');
})
.catch(error => console.error('エラー:', error));
});
clearDispButton.addEventListener('click', () => {
const tableBody = dataTable.querySelector('tbody');
tableBody.innerHTML = '';
originalTableRows = [];
if (myChart) {
myChart.data.datasets = [];
myChart.update();
}
});
saveButton.addEventListener('click', () => {
const rows = dataTable.querySelectorAll("tr");
let csvContent = "data:text/csv;charset=utf-8,";
rows.forEach(row => {
const rowData = [];
const cells = row.querySelectorAll("td, th");
for (let i = 1; i < cells.length; i++) {
let text = cells[i].innerText.replace(/"/g, '""');
if (text.includes(",") || text.includes("\"") || text.includes("\n")) {
text = "\"" + text + "\"";
}
rowData.push(text);
}
csvContent += rowData.join(",") + "\r\n";
});
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "table_data.csv.txt");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
function deleteRow(btn) {
const row = btn.parentNode.parentNode;
row.parentNode.removeChild(row);
originalTableRows = originalTableRows.filter(originalRow => originalRow !== row);
}
document.querySelectorAll('.sort-button').forEach(button => {
button.addEventListener('click', (event) => {
const columnIndex = parseInt(event.target.dataset.column);
const sortOrder = event.target.dataset.order;
sortAndDisplayTable(columnIndex, sortOrder);
});
});
</script>
</body>
</html>
test7.htmlの画面例

【機能説明】
[表示開始][表示停止] : 表の更新の開始・停止
[テーブル保存] : 表示されたテーブルの集計表をCSVでダウンロードします。不要な行は左端の[削除]で削除できます。
[画面表示クリア] : テーブルの内容をクリアします。
[CSVファイルクリア to logファイル] : データがたまった対象CSVファイルの内容を<対象CSVファイル名.log >のファイルに追記し、対象CSVファイルはクリアします。
表頭の▲▼クリックで昇順降順にソートできます。
[グラフ表示開始][グラフ表示停止] : グラフの更新の開始・停止
まとめ
ICタグを製品やパレットなどに貼付し、アンテナの前を通過させ、そのデータを検知して、進捗や入庫・出庫など管理をおこなうといったケースも多いかと思います。電波の強さやアンテナの設置によっては、余計なICタグを検知してしまったり、十分に目的のICタグが検知できなかったりという場合があります。ですので、はじめに設置・調整で電波の出力値や、アンテナの位置・向きを試行錯誤する必要があります。
散布図であればRSSI値のレベルを視覚的にわかりやすく直観的に理解することができます。
FRU/MRUシリーズの場合、設置・調整中のテストで、ここでやったようなRSSI値や検知回数など確認して、適切なRSSIフィルタや、回数フィルタを設定することで、余計なデータを上位に送信してしまう可能性を減らせます。
ここでは「連続モード」としましたが、センサー連携の「トリガモード」等でも同様にデータを収集し、集計を行うことができます。
当社がホームページで公開しておりますツールの「mrurecv」などでも画面である程度は確認可能ですし、「mrurecv」はある程度のCSVデータの出力が可能です。CSVデータがあればEXCELで集計ができます。もちろんEXCELで今回のような散布図グラフを書くのは、むずかしいことではありません。
また、こういったWEBアプリを発展させて、業務システムとすることも可能です。その場合、実運用では、単純なcsvファイルの生成でなく、データベースに格納するようなシステムにしたり、負荷を考えたシステムにする必要がありますので、WEBアプリに長けたシステム会社様にご相談ください。今回は単純にCSVファイルに追記していって、それを集計・表示しているだけですが、多くの場合、読み取りデータは、オンプレであればSQL Server 、Oracle、PostgreSQLといったデータベースにまずは格納します。オンプレなサーバに加えて、多拠点であれば、さらにクラウドを活用するのもよくやられる手法です。WEBサーバもnginxやApache、言語もpython、PHP、Ruby、JAVA、その他各種のフレームワークの活用が一般的です。
※今回の記事の内容はあくまでも実験・テスト用です。記事の利⽤により、万が⼀利⽤者に何らかの損害が⽣じても当社は責任を負わないものとさせていただきます。利⽤者の責任においてご活⽤くださいますよう、お願い申し上げます。

固定式、ハンディ、ゲート型、トンネル型など各種取り揃え

卓上タイプ、タッチパネル端末など各種取り揃え

RFIDシステムのスモールスタートをサポート

金属対応タグ、リネンタグ、耐熱タグなど各種取り揃え