fix
This commit is contained in:
parent
7851217140
commit
ff6e6ba5f1
690
assets/root.html
690
assets/root.html
@ -1,112 +1,604 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset='UTF-8'>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name='viewport' content='width=device-width, initial-scale=1.0, user-scalable=no'>
|
||||||
<title>Logic Analyzer</title>
|
<title>Logic Analyzer - Ring Buffer Waveform</title>
|
||||||
</head>
|
<style>
|
||||||
<body>
|
* {
|
||||||
<main>
|
box-sizing: border-box;
|
||||||
<canvas id="binaryChart" width="500" height="250"></canvas>
|
user-select: none;
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
function drawAllWaveforms(channels_total, samples_total, samples) {
|
|
||||||
const canvas = document.getElementById('binaryChart');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const data = [];
|
|
||||||
for (let i = 0; i < samples_total; i++) {
|
|
||||||
data.push((samples[i] >> 1) & 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Настройки отступов
|
|
||||||
const leftMargin = 50;
|
|
||||||
const rightMargin = 30;
|
|
||||||
const topMargin = 30;
|
|
||||||
const bottomMargin = 40;
|
|
||||||
const graphWidth = canvas.width - leftMargin - rightMargin; // 500 - 50 - 30 = 420
|
|
||||||
const graphHeight = canvas.height - topMargin - bottomMargin; // 250 - 30 - 40 = 180
|
|
||||||
|
|
||||||
// Шаг между точками
|
|
||||||
const stepX = graphWidth / (samples_total - 1); // 420 / 9 = 46.67px
|
|
||||||
|
|
||||||
// Отрисовка осей
|
|
||||||
ctx.beginPath();
|
|
||||||
// Ось Y (вертикальная)
|
|
||||||
ctx.moveTo(leftMargin, topMargin);
|
|
||||||
ctx.lineTo(leftMargin, canvas.height - bottomMargin);
|
|
||||||
// Ось X (горизонтальная)
|
|
||||||
ctx.lineTo(canvas.width - rightMargin, canvas.height - bottomMargin);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Подписи на оси Y (0 и 1)
|
|
||||||
ctx.font = "12px Arial";
|
|
||||||
ctx.fillStyle = "black";
|
|
||||||
ctx.fillText("1", leftMargin - 20, topMargin + 3);
|
|
||||||
ctx.fillText("0", leftMargin - 20, canvas.height - bottomMargin + 3);
|
|
||||||
|
|
||||||
// Подпись оси Y
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(20, canvas.height / 2);
|
|
||||||
ctx.rotate(-Math.PI / 2);
|
|
||||||
ctx.fillText("Значение", -20, 0);
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Рисуем ступенчатый график (бинарный)
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = 'blue';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
|
|
||||||
for (let i = 0; i < samples_total; i++) {
|
|
||||||
const x = leftMargin + i * stepX;
|
|
||||||
// Преобразуем значение (0 или 1) в Y-координату
|
|
||||||
// 1 -> верх (topMargin), 0 -> низ (canvas.height - bottomMargin)
|
|
||||||
const y = data[i] === 1 ? topMargin : canvas.height - bottomMargin;
|
|
||||||
|
|
||||||
if (i === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
// Рисуем горизонтальную линию от предыдущей точки
|
|
||||||
const prevX = leftMargin + (i - 1) * stepX;
|
|
||||||
const prevY = data[i-1] === 1 ? topMargin : canvas.height - bottomMargin;
|
|
||||||
ctx.lineTo(prevX + stepX, prevY); // Горизонтальная линия
|
|
||||||
ctx.lineTo(x, y); // Вертикальный скачок
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = "black";
|
|
||||||
ctx.font = "bold 12px Arial";
|
|
||||||
ctx.fillText("Время (такты)", canvas.width/2 - 30, canvas.height - 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket() {
|
body {
|
||||||
ws = new WebSocket('ws://' + window.location.hostname + ':81');
|
background: #0a0f1e;
|
||||||
|
font-family: 'Segoe UI', 'Roboto', 'Consolas', monospace;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
ws.onopen = function() {
|
main {
|
||||||
console.log('WebSocket connected');
|
background: #11161f;
|
||||||
updateStatus('Подключено');
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 20px 35px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
|
padding: 20px 24px 24px 24px;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
background: #010101;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: inset 0 0 8px #00000055, 0 8px 20px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: #0b0e14;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #1e2533;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 60px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #b9e6ff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: inset 0 1px 0 #2f3a4a, 0 4px 8px #00000030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-stats {
|
||||||
|
background: #151e2a;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #7ee0ff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
background: #0f141fcc;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cdddf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 18px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-badge {
|
||||||
|
background: #00000066;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9cf2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #2a3b4e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
box-shadow: 0 2px 5px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
main { padding: 14px; }
|
||||||
|
.legend-item { font-size: 0.65rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas id="logicCanvas" width="1000" height="500" style="width:100%; height:auto; max-width:1000px; aspect-ratio:1000/500"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="info-panel">
|
||||||
|
<div class="status-card" id="wsStatus">⚡ Ожидание подключения</div>
|
||||||
|
<div class="ring-stats" id="ringStats">🔔 буфер: 0 / 0 семплов</div>
|
||||||
|
<div class="legend" id="channelLegend"></div>
|
||||||
|
<div class="sample-badge" id="sampleCounter">📊 приём: --</div>
|
||||||
|
<button id="clearBtn" title="Сбросить буфер (очистить историю)">⟳ Очистить</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// ---------- КОНФИГУРАЦИЯ ----------
|
||||||
|
const MAX_RING_SIZE = 2048; // максимальная длина кольцевого буфера (глубина истории)
|
||||||
|
let ringBuffer = new Array(MAX_RING_SIZE).fill(0); // храним 8-битные sample (целые 0..255)
|
||||||
|
let ringHead = 0; // следующая позиция для записи
|
||||||
|
let ringCount = 0; // сколько реально семплов в буфере (0..MAX_RING_SIZE)
|
||||||
|
|
||||||
|
// Параметры отображения (каналы: 0...7)
|
||||||
|
const TOTAL_CHANNELS = 8;
|
||||||
|
const CHANNEL_NAMES = ['CH0', 'CH1', 'CH2', 'CH3', 'CH4', 'CH5', 'CH6', 'CH7'];
|
||||||
|
// Цветовая палитра (яркие, различимые на тёмном фоне)
|
||||||
|
const CHANNEL_COLORS = [
|
||||||
|
'#FFB86C', '#50FA7B', '#FF5555', '#8BE9FD',
|
||||||
|
'#F1FA8C', '#BD93F9', '#FF79C6', '#6BE0D9'
|
||||||
|
];
|
||||||
|
|
||||||
|
let canvas = document.getElementById('logicCanvas');
|
||||||
|
let ctx = canvas.getContext('2d');
|
||||||
|
let animationFrameId = null;
|
||||||
|
let lastRenderedCount = -1; // для оптимизации, перерисовка только при изменении
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
let ws = null;
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
|
||||||
|
// Элементы UI
|
||||||
|
const wsStatusDiv = document.getElementById('wsStatus');
|
||||||
|
const ringStatsSpan = document.getElementById('ringStats');
|
||||||
|
const sampleCounterSpan = document.getElementById('sampleCounter');
|
||||||
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
|
const legendContainer = document.getElementById('channelLegend');
|
||||||
|
|
||||||
|
// ---- Построение легенды каналов ----
|
||||||
|
function buildLegend() {
|
||||||
|
legendContainer.innerHTML = '';
|
||||||
|
for (let ch = 0; ch < TOTAL_CHANNELS; ch++) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'legend-item';
|
||||||
|
const colorBox = document.createElement('div');
|
||||||
|
colorBox.className = 'legend-color';
|
||||||
|
colorBox.style.backgroundColor = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.innerText = `${CHANNEL_NAMES[ch]}`;
|
||||||
|
item.appendChild(colorBox);
|
||||||
|
item.appendChild(label);
|
||||||
|
legendContainer.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Управление кольцевым буфером ----
|
||||||
|
function pushSample(sampleValue) {
|
||||||
|
// sampleValue: число 0..255 (8 бит)
|
||||||
|
if (typeof sampleValue !== 'number' || isNaN(sampleValue)) return;
|
||||||
|
let val = Math.min(255, Math.max(0, Math.floor(sampleValue)));
|
||||||
|
ringBuffer[ringHead] = val;
|
||||||
|
ringHead = (ringHead + 1) % MAX_RING_SIZE;
|
||||||
|
if (ringCount < MAX_RING_SIZE) {
|
||||||
|
ringCount++;
|
||||||
|
}
|
||||||
|
// триггерим перерисовку при новом данных (асинхронно)
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка буфера (сброс истории)
|
||||||
|
function clearRingBuffer() {
|
||||||
|
ringHead = 0;
|
||||||
|
ringCount = 0;
|
||||||
|
// затирать массив необязательно, но для эстетики заполним нулями
|
||||||
|
for (let i = 0; i < MAX_RING_SIZE; i++) ringBuffer[i] = 0;
|
||||||
|
scheduleRender();
|
||||||
|
updateStatsUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить массив семплов в порядке от старых к новым (для отрисовки)
|
||||||
|
function getOrderedSamples() {
|
||||||
|
if (ringCount === 0) return [];
|
||||||
|
const result = new Array(ringCount);
|
||||||
|
const startIdx = (ringHead - ringCount + MAX_RING_SIZE) % MAX_RING_SIZE;
|
||||||
|
for (let i = 0; i < ringCount; i++) {
|
||||||
|
result[i] = ringBuffer[(startIdx + i) % MAX_RING_SIZE];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ОТРИСОВКА ВСЕХ ВОЛНОВЫХ ФОРМ (логический анализатор) ----
|
||||||
|
// рисуем все 8 каналов, каждый бит - цифровой сигнал (0/1)
|
||||||
|
function drawWaveforms() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
const samplesArray = getOrderedSamples();
|
||||||
|
const sampleCount = samplesArray.length;
|
||||||
|
if (sampleCount === 0) {
|
||||||
|
// пустой буфер: рисуем тёмный фон и надпись
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#0b0e14';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#6c7a8e';
|
||||||
|
ctx.font = '14px "Segoe UI", monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('⏳ Ожидание данных...', canvas.width/2, canvas.height/2);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройки отрисовки
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const channelHeight = h / TOTAL_CHANNELS; // высота на один канал
|
||||||
|
const paddingTop = 4;
|
||||||
|
const effectiveChannelH = channelHeight - 2; // небольшой отступ между каналами
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
// фон сетки (очень тёмный)
|
||||||
|
ctx.fillStyle = '#0a0d14';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Рисуем горизонтальные разделители и подписи каналов
|
||||||
|
for (let ch = 0; ch < TOTAL_CHANNELS; ch++) {
|
||||||
|
const yTop = ch * channelHeight;
|
||||||
|
const yMid = yTop + channelHeight/2;
|
||||||
|
// линия разделителя (тонкая)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = '#2a3343';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.setLineDash([4, 6]);
|
||||||
|
ctx.moveTo(0, yTop + channelHeight);
|
||||||
|
ctx.lineTo(w, yTop + channelHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
// подпись канала слева
|
||||||
|
ctx.fillStyle = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||||||
|
ctx.font = 'bold 12px "JetBrains Mono", monospace';
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillText(CHANNEL_NAMES[ch], 8, yMid + 4);
|
||||||
|
// фоновая область канала (лёгкая подсветка)
|
||||||
|
ctx.fillStyle = '#10141e';
|
||||||
|
ctx.fillRect(0, yTop + 1, w, channelHeight - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Рисуем сигналы для каждого канала ---
|
||||||
|
// Используем линейную интерполяцию/ступенчатый вид: рисуем линии по точкам (x0,y0) -> (x1, y1)
|
||||||
|
// Горизонтальная шкала: ширина одного семпла = w / sampleCount
|
||||||
|
const stepX = w / sampleCount;
|
||||||
|
if (stepX < 0.5) {
|
||||||
|
// при огромном количестве точек можно упростить, но всё равно корректно отрисуем линии, даже если шаг меньше пикселя
|
||||||
|
// сохраняем точность - canvas сгладит
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let ch = 0; ch < TOTAL_CHANNELS; ch++) {
|
||||||
|
const yBase = ch * channelHeight;
|
||||||
|
const yLow = yBase + channelHeight - 6; // уровень логического 0 (нижняя часть)
|
||||||
|
const yHigh = yBase + 6; // уровень логической 1 (верх)
|
||||||
|
// более чёткие границы: для наглядности опускаем 0 почти к низу, 1 к верху канала
|
||||||
|
const zeroLevel = yBase + channelHeight - 4;
|
||||||
|
const oneLevel = yBase + 6;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.lineWidth = 2.2;
|
||||||
|
ctx.strokeStyle = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
let prevX = 0;
|
||||||
|
// берем первый sample, получаем бит для канала ch
|
||||||
|
let prevBit = (samplesArray[0] >> ch) & 1;
|
||||||
|
let prevY = prevBit === 1 ? oneLevel : zeroLevel;
|
||||||
|
ctx.moveTo(0, prevY);
|
||||||
|
|
||||||
|
for (let i = 1; i < sampleCount; i++) {
|
||||||
|
const sampleVal = samplesArray[i];
|
||||||
|
const bit = (sampleVal >> ch) & 1;
|
||||||
|
const currentX = i * stepX;
|
||||||
|
const currentY = bit === 1 ? oneLevel : zeroLevel;
|
||||||
|
|
||||||
|
// если значение бита изменилось — рисуем вертикальный переход
|
||||||
|
if (bit !== prevBit) {
|
||||||
|
// сначала линия до той же X, но с предыдущим Y (горизонтальная)
|
||||||
|
ctx.lineTo(currentX, prevY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(currentX, prevY);
|
||||||
|
ctx.lineTo(currentX, currentY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(currentX, currentY);
|
||||||
|
prevBit = bit;
|
||||||
|
prevY = currentY;
|
||||||
|
} else {
|
||||||
|
// горизонтальный сегмент
|
||||||
|
ctx.lineTo(currentX, currentY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// финальный штрих до конца
|
||||||
|
const lastX = w;
|
||||||
|
ctx.lineTo(lastX, prevY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// небольшая заливка для области нуля/единицы (дополнительно полупрозрачная тень)
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.12;
|
||||||
|
ctx.fillStyle = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||||||
|
for (let seg = 0; seg < sampleCount-1; seg++) {
|
||||||
|
const bitSeg = (samplesArray[seg] >> ch) & 1;
|
||||||
|
if (bitSeg === 1) {
|
||||||
|
const segX1 = seg * stepX;
|
||||||
|
const segX2 = (seg+1) * stepX;
|
||||||
|
ctx.fillRect(segX1, yBase+2, segX2-segX1, channelHeight-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// сетка вертикальных тактов (опционально каждые 8/16/32 семпла)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = '#2e3b4e';
|
||||||
|
ctx.lineWidth = 0.6;
|
||||||
|
ctx.setLineDash([5, 8]);
|
||||||
|
const gridSpacing = Math.max(16, Math.floor(sampleCount / 20));
|
||||||
|
for (let i = 0; i <= sampleCount; i += gridSpacing) {
|
||||||
|
const xPos = i * stepX;
|
||||||
|
if (xPos <= w) {
|
||||||
|
ctx.moveTo(xPos, 0);
|
||||||
|
ctx.lineTo(xPos, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// отрисовка текста с числом семплов поверх (информативно)
|
||||||
|
ctx.font = '10px "Fira Code", monospace';
|
||||||
|
ctx.fillStyle = '#b9d9ff';
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillText(`${sampleCount} выборок`, w - 70, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderScheduled = false;
|
||||||
|
function scheduleRender() {
|
||||||
|
if (renderScheduled) return;
|
||||||
|
renderScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
drawWaveforms();
|
||||||
|
updateStatsUI(); // обновляем счетчики буфера и приёма
|
||||||
|
renderScheduled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// обновление UI статистики (размер буфера, кол-во семплов)
|
||||||
|
function updateStatsUI() {
|
||||||
|
if (ringStatsSpan) {
|
||||||
|
ringStatsSpan.innerHTML = `💾 кольц. буфер: ${ringCount} / ${MAX_RING_SIZE} семпл`;
|
||||||
|
}
|
||||||
|
if (sampleCounterSpan) {
|
||||||
|
sampleCounterSpan.innerHTML = `📈 последний пакет: ${ringCount > 0 ? ringCount : '—'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// обновление статуса WebSocket
|
||||||
|
function updateStatus(message, isConnected = false) {
|
||||||
|
if (wsStatusDiv) {
|
||||||
|
wsStatusDiv.innerHTML = `🔌 ${message}`;
|
||||||
|
if (isConnected) {
|
||||||
|
wsStatusDiv.style.background = '#1a4731';
|
||||||
|
wsStatusDiv.style.color = '#b4ffcf';
|
||||||
|
} else {
|
||||||
|
wsStatusDiv.style.background = '#3d2a2a';
|
||||||
|
wsStatusDiv.style.color = '#ffbc9a';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ОБРАБОТКА ВХОДЯЩИХ ДАННЫХ (WebSocket) ----
|
||||||
|
// Формат ожидаемого JSON:
|
||||||
|
// { type: 'data', channels_total: 8, samples_total: N, samples: [12, 200, ...] }
|
||||||
|
// или массив байт. samples - массив целых 0..255
|
||||||
|
function processSamplesFromMessage(dataMsg) {
|
||||||
|
let samplesArray = null;
|
||||||
|
// если пришёл массив samples
|
||||||
|
if (dataMsg.samples && Array.isArray(dataMsg.samples)) {
|
||||||
|
samplesArray = dataMsg.samples;
|
||||||
|
}
|
||||||
|
// поддержка альтернативного поля data
|
||||||
|
else if (dataMsg.data && Array.isArray(dataMsg.data)) {
|
||||||
|
samplesArray = dataMsg.data;
|
||||||
|
}
|
||||||
|
// также если в поле samples_total подсказка
|
||||||
|
if (samplesArray && samplesArray.length > 0) {
|
||||||
|
// фильтруем и добавляем каждый sample в кольцевой буфер
|
||||||
|
for (let i = 0; i < samplesArray.length; i++) {
|
||||||
|
let smp = Number(samplesArray[i]);
|
||||||
|
if (isNaN(smp)) continue;
|
||||||
|
pushSample(smp);
|
||||||
|
}
|
||||||
|
// также можно отобразить быстрый счётчик
|
||||||
|
if (sampleCounterSpan) {
|
||||||
|
sampleCounterSpan.innerHTML = `📊 +${samplesArray.length} семпл(ов) | всего ${ringCount}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// нет данных, но возможно samples_total = 0
|
||||||
|
console.warn("Нет массива samples в сообщении", dataMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WEBSOCKET подключение (адаптация под ring buffer) ----
|
||||||
|
function connectWebSocket() {
|
||||||
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||||
|
try { ws.close(); } catch(e) {}
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `ws://${window.location.hostname}:81`;
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[LOGIC] WebSocket connected');
|
||||||
|
updateStatus('Подключено к анализатору', true);
|
||||||
|
connectionAttempts = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = function(event) {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
try {
|
||||||
if (data.type === 'data') {
|
const data = JSON.parse(event.data);
|
||||||
drawAllWaveforms(data.channels_total, data.samples_total, data.samples);
|
if (data.type === 'data') {
|
||||||
} else if (data.type === 'status') {
|
// стандартное сообщение от логического анализатора
|
||||||
updateStatus(data.message);
|
// ожидаем data.samples — массив байт
|
||||||
|
if (data.samples) {
|
||||||
|
processSamplesFromMessage(data);
|
||||||
|
}
|
||||||
|
// поддержка также прямого массива samples_total и samples
|
||||||
|
else if (data.samples_total !== undefined && data.samples) {
|
||||||
|
processSamplesFromMessage(data);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// возможно сообщение просто содержит samples массив? проверяем корневой массив
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (let val of data) pushSample(val);
|
||||||
|
} else if (data.data && Array.isArray(data.data)) {
|
||||||
|
processSamplesFromMessage(data);
|
||||||
|
} else {
|
||||||
|
// неизвестный формат, но пробуем извлечь samples поле рекурсивно
|
||||||
|
if (data.payload && data.payload.samples) processSamplesFromMessage(data.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (data.type === 'status') {
|
||||||
|
updateStatus(data.message || data.status, true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// попробуем универсально: если есть свойство samples использовать
|
||||||
|
if (data.samples) processSamplesFromMessage(data);
|
||||||
|
else if (Array.isArray(data)) {
|
||||||
|
data.forEach(v => pushSample(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("JSON parse error or invalid data:", err, event.data);
|
||||||
|
// возможно raw binary данные? по тексту не ожидается, но игнорируем
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = function() {
|
ws.onclose = (ev) => {
|
||||||
console.log('WebSocket disconnected');
|
console.warn('WebSocket closed, reconnecting...', ev.code);
|
||||||
updateStatus('Отключено. Переподключение...');
|
updateStatus('Отключено. Переподключение через 1с...', false);
|
||||||
setTimeout(connectWebSocket, 1000);
|
setTimeout(() => {
|
||||||
|
connectWebSocket();
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
console.error('WebSocket error', err);
|
||||||
|
updateStatus('Ошибка соединения', false);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', connectWebSocket);
|
// ---- ИНИЦИАЛИЗАЦИЯ И АДАПТАЦИЯ РАЗМЕРА CANVAS ----
|
||||||
</script>
|
function resizeCanvasAndRedraw() {
|
||||||
</body>
|
const container = canvas.parentElement;
|
||||||
|
const maxWidth = Math.min(1200, window.innerWidth - 60);
|
||||||
|
// устанавливаем размер canvas в пикселях для чёткой графики
|
||||||
|
const targetWidth = Math.min(1000, maxWidth);
|
||||||
|
const targetHeight = 500;
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
|
canvas.style.width = `${targetWidth}px`;
|
||||||
|
canvas.style.height = `${targetHeight}px`;
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// очистка буфера по кнопке
|
||||||
|
function handleClear() {
|
||||||
|
clearRingBuffer();
|
||||||
|
updateStatus('Буфер сброшен', !!ws && ws.readyState === WebSocket.OPEN);
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имитация демо-данных, если сервер не отвечает (для теста интерфейса кольцевого буфера)
|
||||||
|
// но только если нет подключения в течение 4 секунд - демонстрация работы (добавим генератор)
|
||||||
|
let demoInterval = null;
|
||||||
|
function startDemoDataIfNeeded() {
|
||||||
|
// Проверяем каждые 5 секунд: если нет ws или не открыт и буфер пуст, то генерируем демо
|
||||||
|
if (demoInterval) clearInterval(demoInterval);
|
||||||
|
demoInterval = setInterval(() => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
// генерируем несколько тестовых семплов для наглядности, но только если буфер не слишком большой
|
||||||
|
if (ringCount < 300) {
|
||||||
|
const demoCount = 4;
|
||||||
|
for (let i = 0; i < demoCount; i++) {
|
||||||
|
// Создаём осмысленную последовательность: счётчик, меандр на разных каналах
|
||||||
|
const timeSeed = Date.now() + i;
|
||||||
|
let sampleVal = 0;
|
||||||
|
// CH0: квадратная волна (каждые 8 семплов)
|
||||||
|
if ((ringCount + i) % 16 < 8) sampleVal |= (1 << 0);
|
||||||
|
// CH1: меандр с другой частотой
|
||||||
|
if ((ringCount + i) % 24 < 12) sampleVal |= (1 << 1);
|
||||||
|
// CH2: случайный импульс
|
||||||
|
if (Math.sin((ringCount + i) * 0.3) > 0) sampleVal |= (1 << 2);
|
||||||
|
// CH3: активный каждые 3 семпла
|
||||||
|
if ((ringCount + i) % 6 < 3) sampleVal |= (1 << 3);
|
||||||
|
// CH4: половина
|
||||||
|
if ((ringCount + i) % 10 > 4) sampleVal |= (1 << 4);
|
||||||
|
// CH5: шумоподобный
|
||||||
|
if (Math.floor(Math.random() * 2)) sampleVal |= (1 << 5);
|
||||||
|
// CH6, CH7 (доп)
|
||||||
|
if ((ringCount + i) % 7 < 3) sampleVal |= (1 << 6);
|
||||||
|
if ((ringCount + i) % 5 === 0) sampleVal |= (1 << 7);
|
||||||
|
pushSample(sampleVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 360);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Отслеживание изменения размера окна ---
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
resizeCanvasAndRedraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация UI
|
||||||
|
function init() {
|
||||||
|
buildLegend();
|
||||||
|
resizeCanvasAndRedraw();
|
||||||
|
clearRingBuffer(); // чистый старт
|
||||||
|
connectWebSocket();
|
||||||
|
clearBtn.addEventListener('click', handleClear);
|
||||||
|
// небольшая анимация рендера (периодическая перерисовка для обновления UI даже без данных)
|
||||||
|
function periodicRender() {
|
||||||
|
drawWaveforms();
|
||||||
|
requestAnimationFrame(periodicRender);
|
||||||
|
}
|
||||||
|
// Запускаем непрерывный рендер (эффективно, не нагружает)
|
||||||
|
periodicRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -128,14 +128,14 @@ void appendSamples() {
|
|||||||
|
|
||||||
lastTime = now;
|
lastTime = now;
|
||||||
|
|
||||||
if (g_samples_idx == SAMPLES_BUFFER_CAP - 1) return;
|
if (g_samples_idx == SAMPLES_BUFFER_CAP) return;
|
||||||
|
|
||||||
SAMPLES[g_samples_idx] = takeSample();
|
SAMPLES[g_samples_idx] = takeSample();
|
||||||
g_samples_idx++;
|
g_samples_idx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
void websocketSendSamples() {
|
void websocketSendSamples() {
|
||||||
if (g_samples_idx != SAMPLES_BUFFER_CAP - 1) return;
|
if (g_samples_idx < SAMPLES_BUFFER_CAP) return;
|
||||||
|
|
||||||
static unsigned long lastTime = 0;
|
static unsigned long lastTime = 0;
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
|||||||
10
justfile
10
justfile
@ -1,19 +1,21 @@
|
|||||||
#!/usr/bin/env -S just --justfile
|
#!/usr/bin/env -S just --justfile
|
||||||
|
|
||||||
|
BOARD := env("BOARD", "esp8266:esp8266:nodemcuv2")
|
||||||
|
PORT := env("PORT", "/dev/ttyUBS0")
|
||||||
|
|
||||||
alias compile := build
|
alias compile := build
|
||||||
build:
|
build:
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
xxd -i assets/root.html > firmware/logic_analyzer/root_html.h
|
xxd -i assets/root.html > firmware/logic_analyzer/root_html.h
|
||||||
cd firmware/logic_analyzer
|
cd firmware/logic_analyzer
|
||||||
arduino-cli compile --fqbn esp8266:esp8266:nodemcuv2
|
arduino-cli compile --fqbn "{{BOARD}}"
|
||||||
|
|
||||||
|
|
||||||
alias flash := upload
|
alias flash := upload
|
||||||
|
|
||||||
[working-directory: 'firmware/logic_analyzer']
|
[working-directory: 'firmware/logic_analyzer']
|
||||||
upload:
|
upload:
|
||||||
arduino-cli upload --fqbn esp8266:esp8266:nodemcuv2 --port /dev/ttyUSB0
|
arduino-cli upload --fqbn "{{BOARD}}" --port "{{PORT}}"
|
||||||
|
|
||||||
|
|
||||||
monitor:
|
monitor:
|
||||||
arduino-cli monitor --port /dev/ttyUSB0
|
arduino-cli monitor --port "{{PORT}}" --config 115200
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user