diff --git a/src/playground/blocks/hardware/block_jikko_animal.js b/src/playground/blocks/hardware/block_jikko_animal.js new file mode 100644 index 0000000000..7e1c1130d4 --- /dev/null +++ b/src/playground/blocks/hardware/block_jikko_animal.js @@ -0,0 +1,794 @@ +'use strict'; + +/** + * 애니멀 키링 EntryJS 하드웨어 블록 모듈 + * + * 명령 전송 흐름: + * 엔트리 블록 -> sendProtocol() -> Entry.hw.sendQueue.SET + * -> Entry Hardware -> 애니멀 키링 펌웨어 + * + * 데이터 수신 흐름: + * 애니멀 키링 펌웨어 -> Entry Hardware -> Entry.hw.portData + * -> afterReceive() 및 입력값 블록 + */ +Entry.ANIMALKEYRING = new (class ANIMALKEYRING { + // 하드웨어 정보, 통신 규격, 고정 핀과 LED 상태를 준비한다. + constructor() { + this.id = '47.5'; + this.name = 'ANIMALKEYRING'; + this.url = 'https://www.makeitall.co.kr/'; + this.imageName = 'jikko_Animal.png'; + this.title = { ko: '애니멀 키링', en: 'ANIMAL KEYRING' }; + + // 통신 프로토콜 V2.0.1 정리본을 기준으로 정의한다. + this.protocol = { + HEADER_1: 0xff, + HEADER_2: 0xfd, + instruction: { ETC: 0xff, READ_MASK: 0x40 }, + device: { + DIGITAL: 0x01, + ANALOG: 0x02, + NEOPIXEL: 0x04, + BUZZER: 0x06, + DOT_MATRIX: 0x08, + }, + action: { + INIT: 0x01, + CLEAR: 0x02, + OUTPUT: 0x05, + BITMAP: 0x0d, + PLAY_MELODY: 0x0b, + STOP: 0x0c, + }, + melody: { + THREE_BEARS: 0x00, + ROUND_AND_ROUND: 0x01, + MOUNTAIN_TIGER: 0x02, + RUDOLPH: 0x03, + TWINKLE_TWINKLE: 0x04, + EXCITING: 0x05, + CHEERFUL: 0x06, + SOFT: 0x07, + GRAND: 0x08, + URGENT: 0x09, + }, + }; + + // 애니멀 보드의 고정 배선 정보이며 보드 버전이 바뀔 때만 수정한다. + this.pins = { + LED: 5, + BUZZER: 6, + NEOPIXEL: 10, + DOT_MATRIX: { DIN: 12, CS: 11, CLK: 9 }, + }; + this.sensorSubscriptions = {}; + this.digitalPortData = { 2: 1, 3: 1 }; + this.neopixelCount = 12; + this.neopixelOrder = [3, 4, 5, 9, 10, 11, 0, 1, 2, 6, 7, 8]; + this.neopixelDefaultLed = Array.from({ length: 6 }, () => + Array.from({ length: 2 }, () => 0) + ); + this.dotMatrixDefaultLed = Array.from({ length: 8 }, () => + Array.from({ length: 8 }, () => 0) + ); + this.blockMenuBlocks = [ + 'makeitall_led_title', + 'makeitall_sensor_led', + 'makeitall_sensor_led_brightness', + 'makeitall_input_title', + 'makeitall_input', + 'makeitall_piezobuzzer_title', + 'makeitall_sensor_piezobuzzer', + 'makeitall_sensor_morsedot', + 'makeitall_sensor_morseline', + 'makeitall_sensor_playmelody', + 'makeitall_sensor_box', + 'makeitall_neo_title', + 'makeitall_neo_exression_rainbowcolor', + 'makeitall_neo_expression_color', + 'makeitall_neo_bitmap_rainbowcolor', + 'makeitall_neo_bitmap_color', + 'makeitall_neo_clear', + 'makeitall_dotmatrix_title', + 'makeitall_dotmatrix_bitmap', + 'makeitall_dotmatrix', + 'makeitall_dotmatrix_clear', + ]; + } + + // FF FD 패킷을 만들고 CRC-16/MODBUS를 계산하는 공통 처리 + clampByte(value) { + return Math.max(0, Math.min(255, Math.round(Number(value) || 0))); + } + + // CRC-16/MODBUS를 계산하고 V2.0.1 규격에 따라 하위·상위 바이트 순서로 붙인다. + calculateCrc(bytes) { + let crc = 0xffff; + bytes.forEach((byte) => { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xa001 : crc >>> 1; + } + }); + return crc & 0xffff; + } + + buildPacket(instruction, parameters) { + const params = (parameters || []).map((value) => this.clampByte(value)); + const normalizedInstruction = this.clampByte(instruction); + const body = [params.length, normalizedInstruction].concat(params); + const withoutCrc = [this.protocol.HEADER_1, this.protocol.HEADER_2].concat(body); + const crc = this.calculateCrc([normalizedInstruction].concat(params)); + return withoutCrc.concat([crc & 0xff, (crc >>> 8) & 0xff]); + } + + // EntryJS 블록 명령을 Entry Hardware 전송 큐에 기록한다. + sendProtocol(instruction, parameters, options) { + const packet = this.buildPacket(instruction, parameters); + Entry.hw.sendQueue = Entry.hw.sendQueue || {}; + Entry.hw.sendQueue.SET = { + protocol: '2.0.1', + instruction: this.clampByte(instruction), + parameters: parameters.map((value) => this.clampByte(value)), + packet, + time: options && options.time ? options.time : Date.now(), + }; + Entry.hw.update(); + delete Entry.hw.sendQueue.SET; + } + + // Entry Hardware가 해석한 보드 응답을 EntryJS 입력 상태에 반영한다. + afterReceive(portData) { + const mcuPackets = portData && portData.MCU_PACKETS; + if (Array.isArray(mcuPackets)) { + mcuPackets.forEach(({ instruction, params, parameters }) => { + const receivedParameters = params || parameters; + this.updateDigitalPortData(portData, instruction, receivedParameters); + }); + } + const blockPackets = portData && portData.BLOCK_PACKETS; + if (Array.isArray(blockPackets)) { + blockPackets.forEach(({ device, instruction, parameters }) => { + const params = device === undefined + ? parameters + : [device].concat(parameters || []); + this.updateDigitalPortData(portData, instruction, params); + }); + } + if (portData && portData.instruction !== undefined) { + this.updateDigitalPortData( + portData, + portData.instruction, + portData.params || portData.parameters + ); + } + } + + writePin(pin, device, value) { + this.sendProtocol(pin, [device, value]); + } + + updateDigitalPortData(portData, instruction, parameters) { + if (!portData || !Array.isArray(parameters) || parameters.length < 2) return; + if (Number(parameters[0]) !== this.protocol.device.DIGITAL) return; + + const rawInstruction = Number(instruction); + if (!Number.isFinite(rawInstruction)) return; + + const pin = rawInstruction & 0x3f; + const value = this.clampByte(parameters[parameters.length - 1]); + if (value > 1) return; + + this.digitalPortData[pin] = value; + portData[pin] = value; + portData[`digital_${pin}`] = value; + } + + getDigitalPortValue(pin) { + const portData = Entry.hw.portData; + const pinNumber = this.clampByte(pin); + if (!portData) return this.digitalPortData[pinNumber]; + + const keys = [pinNumber, `digital_${pinNumber}`]; + const key = keys.find((candidate) => portData[candidate] !== undefined); + if (key !== undefined) { + const value = this.clampByte(portData[key]); + if (value <= 1) this.digitalPortData[pinNumber] = value; + } + return this.digitalPortData[pinNumber]; + } + + subscribeDigital(pin) { + const key = `digital:${pin}`; + const now = Date.now(); + const lastRequestedAt = this.sensorSubscriptions[key] || 0; + if (now - lastRequestedAt < 50) return; + this.sensorSubscriptions[key] = now; + this.sendProtocol( + this.protocol.instruction.READ_MASK + this.clampByte(pin), + [this.protocol.device.DIGITAL] + ); + } + + hexToRgb555(color) { + const value = String(color || '#000000').replace('#', ''); + const r = parseInt(value.slice(0, 2), 16) || 0; + const g = parseInt(value.slice(2, 4), 16) || 0; + const b = parseInt(value.slice(4, 6), 16) || 0; + return ((r >>> 3) << 10) | ((g >>> 3) << 5) | (b >>> 3); + } + + scaleHexColor(color, brightness, maximum = 9) { + const value = String(color || '#000000').replace('#', ''); + const ratio = Math.max(0, Math.min(1, (Number(brightness) || 0) / maximum)); + const scale = (offset) => + Math.round((parseInt(value.slice(offset, offset + 2), 16) || 0) * ratio); + return `#${[scale(0), scale(2), scale(4)] + .map((channel) => channel.toString(16).padStart(2, '0')) + .join('')}`; + } + + sendNeoPixelPattern(colors) { + const frame = new Array(this.neopixelCount).fill(0); + + colors.slice(0, this.neopixelCount).forEach(({ index, color }) => { + const normalizedIndex = this.clampByte(index); + if (normalizedIndex < this.neopixelCount) { + frame[normalizedIndex] = this.hexToRgb555(color); + } + }); + + // 12개 LED 색상을 한 패킷으로 묶어 동시에 갱신한다. + // RGB555는 기존 개별 LED 명령과 동일하게 상위 바이트부터 전송한다. + const parameters = [ + this.protocol.device.NEOPIXEL, + this.protocol.action.BITMAP, + this.neopixelCount, + ]; + frame.forEach((rgb555) => { + parameters.push((rgb555 >>> 8) & 0xff, rgb555 & 0xff); + }); + this.sendProtocol(this.pins.NEOPIXEL, parameters); + + return frame.length; + } + + clearNeoPixel() { + this.sendProtocol(this.pins.NEOPIXEL, [ + this.protocol.device.NEOPIXEL, + this.protocol.action.CLEAR, + ]); + } + + ledFieldToRows(value, size) { + const matrix = Array.isArray(value) ? value : []; + return Array.from({ length: size }, (_, row) => { + let result = 0; + for (let col = 0; col < size; col++) { + if (matrix[row] && Number(matrix[row][col])) result |= 1 << (size - col - 1); + } + return result; + }); + } + + led2FieldToPixels(value) { + const matrix = Array.isArray(value) ? value : []; + return matrix + .reduce( + (pixels, cells) => pixels.concat(Array.isArray(cells) ? cells : []), + [] + ) + .slice(0, this.neopixelCount) + .map((brightness) => Math.max(0, Math.min(9, Number(brightness) || 0))); + } + + patternStringToRows(value) { + return String(value || '') + .split(':') + .slice(0, 8) + .map((row) => parseInt(row, 2) || 0); + } + + sendDotMatrixRows(rows) { + const normalized = rows.slice(0, 8).map((value) => this.clampByte(value)); + while (normalized.length < 8) normalized.push(0); + this.sendProtocol(this.protocol.instruction.ETC, [ + this.protocol.device.DOT_MATRIX, + this.protocol.action.OUTPUT, + ].concat(normalized)); + } + + frequencyToNote(frequency) { + const hz = Math.max(1, Number(frequency) || 440); + const midi = Math.max(0, Math.min(127, Math.round(69 + 12 * Math.log2(hz / 440)))); + const supportedNotes = [ + { semitone: 0, note: 1 }, + { semitone: 1, note: 8 }, + { semitone: 2, note: 2 }, + { semitone: 3, note: 9 }, + { semitone: 4, note: 3 }, + { semitone: 5, note: 4 }, + { semitone: 6, note: 10 }, + { semitone: 7, note: 5 }, + { semitone: 8, note: 11 }, + { semitone: 9, note: 6 }, + { semitone: 10, note: 12 }, + { semitone: 11, note: 7 }, + ]; + let closest = { distance: Infinity, octave: 4, note: 6 }; + for (let octave = 0; octave <= 8; octave++) { + supportedNotes.forEach(({ semitone, note }) => { + const noteMidi = (octave + 1) * 12 + semitone; + const distance = Math.abs(noteMidi - midi); + if (distance < closest.distance) { + closest = { distance, octave, note }; + } + }); + } + return (closest.octave << 4) | closest.note; + } + + // 작품 정지 시 LED, 부저, 네오픽셀과 도트매트릭스를 안전하게 끈다. + setZero() { + this.sensorSubscriptions = {}; + this.digitalPortData = { 2: 1, 3: 1 }; + this.writePin(this.pins.LED, this.protocol.device.DIGITAL, 0); + this.sendProtocol(this.pins.BUZZER, [ + this.protocol.device.BUZZER, + this.protocol.action.STOP, + ]); + this.clearNeoPixel(); + this.sendDotMatrixRows(Array(8).fill(0)); + } + + // 블록 화면에 표시할 한국어와 영어 문구를 정의한다. + setLanguage() { + const dotTemplate = '8x8 도트매트릭스 LED 그리기 %1 %2'; + return { + ko: { + template: { + makeitall_led_title: 'LED', + makeitall_sensor_led: 'LED %1', + makeitall_sensor_led_brightness: 'LED 밝기 %1 출력 (0 ~ 255)', + makeitall_input_title: '입력', + makeitall_input: '%1 버튼 눌림 상태', + makeitall_piezobuzzer_title: '피에조 부저', + makeitall_sensor_piezobuzzer: '%1 음을 %2 박자 연주', + makeitall_sensor_morsedot: '모스부호 점 (.) 출력', + makeitall_sensor_morseline: '모스부호 선 (-) 출력', + makeitall_sensor_playmelody: '피에조부저 %1 (번째) 곡 %2초 (1~20) 연주', + makeitall_sensor_box: '%1', + makeitall_neo_title: '네오픽셀', + makeitall_neo_exression_rainbowcolor: '네오픽셀 LED 표정 %1을 %2로 출력', + makeitall_neo_expression_color: '네오픽셀 LED 표정 %1을 %2로 출력', + makeitall_neo_bitmap_rainbowcolor: '네오픽셀 LED 모양 %1을 %2로 출력', + makeitall_neo_bitmap_color: '네오픽셀 LED 모양 %1을 %2로 출력', + makeitall_neo_clear: '네오픽셀 LED 모두 끄기', + makeitall_dotmatrix_title: '도트매트릭스', + makeitall_dotmatrix_bitmap: dotTemplate, + makeitall_dotmatrix: '8x8 도트매트릭스 LED 그리기 %1', + makeitall_dotmatrix_clear: '8x8 도트매트릭스 지우기', + }, + }, + en: { + template: { + makeitall_led_title: 'LED', + makeitall_sensor_led: 'LED %1', + makeitall_sensor_led_brightness: 'LED brightness %1 (0-255)', + makeitall_input_title: 'Input', + makeitall_input: '%1 button pressed', + makeitall_piezobuzzer_title: 'Piezo buzzer', + makeitall_sensor_piezobuzzer: 'Play note %1 for %2 beats', + makeitall_sensor_morsedot: 'Play Morse dot (.)', + makeitall_sensor_morseline: 'Play Morse dash (-)', + makeitall_sensor_playmelody: 'Play buzzer song %1 for %2 seconds (1~20)', + makeitall_sensor_box: '%1', + makeitall_neo_title: 'NeoPixel', + makeitall_neo_exression_rainbowcolor: 'Show expression %1 in %2', + makeitall_neo_expression_color: 'Show expression %1 in %2', + makeitall_neo_bitmap_rainbowcolor: 'Show pattern %1 in %2', + makeitall_neo_bitmap_color: 'Show pattern %1 in %2', + makeitall_neo_clear: 'Clear all NeoPixel LEDs', + makeitall_dotmatrix_title: 'Dot matrix', + makeitall_dotmatrix_bitmap: 'Draw 8x8 dot matrix %1 %2', + makeitall_dotmatrix: 'Draw 8x8 dot matrix %1', + makeitall_dotmatrix_clear: 'Clear 8x8 dot matrix', + }, + }, + }; + } + + ensureAnimalLedField() { + if (Object.prototype.hasOwnProperty.call(Entry, 'FieldAnimalLed')) { + return; + } + + Object.defineProperty(Entry, 'FieldAnimalLed', { + configurable: true, + get() { + const FieldAnimalLed = class FieldAnimalLed extends Entry.FieldLed2 { + constructor(content, blockView, index) { + super(content, blockView, index); + + const { rows, columns } = content; + const currentValue = this.getValue(); + const hasRequestedSize = + Array.isArray(currentValue) && + currentValue.length === rows && + currentValue.every( + (row) => Array.isArray(row) && row.length === columns + ); + + if (rows && columns && !hasRequestedSize) { + this.setValue( + Array.from({ length: rows }, () => + Array.from({ length: columns }, () => 0) + ) + ); + this.renderLed(); + } + } + + renderLed() { + const ledStatus = this.getValue(); + const currentStatus = ledStatus.params || ledStatus; + const rowCount = currentStatus.length; + const columnCount = currentStatus.reduce( + (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), + 0 + ); + const ledScale = 5 / Math.max(rowCount, columnCount, 5); + const ledDist = 3 * ledScale; + const ledOffset = 0.5 * ledScale; + + (this._rect || []).forEach((row) => + row.forEach((rect) => rect && rect.remove()) + ); + this._rect = Array.from({ length: rowCount }, () => []); + + currentStatus.forEach((leds, row) => { + leds.forEach((led, column) => { + this._rect[row][column] = this.svgGroup.elem('rect', { + x: column * ledDist + 4, + y: row * ledDist - 8 + ledOffset, + width: ledDist - ledOffset, + height: ledDist - ledOffset, + rx: 0.5, + ry: 0.5, + fill: led ? '#ffffff' : '#00b6b1', + }); + }); + }); + } + }; + + Object.defineProperty(Entry, 'FieldAnimalLed', { + configurable: true, + writable: true, + value: FieldAnimalLed, + }); + return FieldAnimalLed; + }, + }); + } + + // 각 애니멀 키링 블록의 모양, 입력값과 실행 명령을 정의한다. + getBlocks() { + this.ensureAnimalLedField(); + const hardwareColor = EntryStatic.colorSet.block.default.HARDWARE; + const hardwareDark = EntryStatic.colorSet.block.darken.HARDWARE; + const indicator = { type: 'Indicator', img: 'block_icon/hardware_icon.svg', size: 12 }; + const dropdown = (options, value) => ({ + type: 'Dropdown', options, value, fontSize: 11, bgColor: hardwareDark, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }); + const basic = (className, params, def, paramsKeyMap, func) => ({ + color: hardwareColor, + outerLine: hardwareDark, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + params: params.concat(indicator), + def, + paramsKeyMap, + isNotFor: ['ANIMALKEYRING'], + class: className, + func, + }); + const title = (blockName, template, className) => ({ + skeleton: 'basic_text', + color: EntryStatic.colorSet.common.TRANSPARENT, + fontColor: '#333333', + skeletonOptions: { contentPos: { x: 10, y: 10 } }, + params: [{ type: 'Text', text: template, color: '#333333', align: 'left' }], + def: { type: blockName }, + isNotFor: ['ANIMALKEYRING'], + class: className, + fontSize: 22, + }); + const colorOptions = [ + ['빨강', '#FF0000'], ['주황', '#FF7F00'], ['노랑', '#FFFF00'], + ['초록', '#00C853'], ['파랑', '#0088FF'], ['보라', '#7E57C2'], + ['검정', '#000000'], ['하양', '#FFFFFF'], + ]; + const expressions = [ + ['기쁨', '1'], + ['삐진', '2'], + ['짜릿한', '3'], + ['슬픈', '4'], + ['놀란', '5'], + ['졸린', '6'], + ['웃는', '7'], + ['멍한', '8'], + ['속상한', '9'], + ]; + const melodyIds = Entry.ANIMALKEYRING.protocol.melody; + const melodyOptions = [ + ['곰 세 마리', melodyIds.THREE_BEARS], + ['둥글게 둥글게', melodyIds.ROUND_AND_ROUND], + ['산중호걸', melodyIds.MOUNTAIN_TIGER], + ['루돌프 사슴코', melodyIds.RUDOLPH], + ['작은 별', melodyIds.TWINKLE_TWINKLE], + ['신나는', melodyIds.EXCITING], + ['경쾌한', melodyIds.CHEERFUL], + ['부드러운', melodyIds.SOFT], + ['웅장한', melodyIds.GRAND], + ['긴박한', melodyIds.URGENT], + ]; + const buzzerSoundOptions = melodyOptions.map(([name, id]) => [ + `${id}.${name.replace(/ /g, '')}`, + id, + ]); + const noteOptions = [ + ['도', '262'], ['도#', '277'], ['레', '294'], ['레#', '311'], + ['미', '330'], ['파', '349'], ['파#', '370'], ['솔', '392'], + ['솔#', '415'], ['라', '440'], ['라#', '466'], ['시', '494'], + ]; + const dotIcons = [ + ['🖤', '01100110:11111111:11111111:11111111:11111111:01111110:00111100:00011000'], + ['🤍', '01100110:10011001:10000001:10000001:01000010:00100100:00011000:00000000'], + ['⭕', '00111100:01000010:10000001:10000001:10000001:10000001:01000010:00111100'], + ['❌', '10000001:01000010:00100100:00011000:00011000:00100100:01000010:10000001'], + ['👆', '00011000:00111100:01111110:11011011:00011000:00011000:00011000:00011000'], + ['👇', '00011000:00011000:00011000:00011000:11011011:01111110:00111100:00011000'], + ['👉', '00001000:00001100:11111110:11111111:11111111:11111110:00001100:00001000'], + ['👈', '00010000:00110000:01111111:11111111:11111111:01111111:00110000:00010000'], + ['😊', '00111100:01000010:10100101:10000001:10100101:10011001:01000010:00111100'], + ['😢', '00111100:01000010:10100101:10000001:10011001:10100101:01000010:00111100'], + ['😡', '00111100:01000010:10011001:10100101:10000001:10111101:01000010:00111100'], + ['😆', '00111100:01000010:10100101:01011010:10000001:10100101:01011010:00111100'], + ]; + const expressionRows = (expression) => { + const patterns = [ + '101010101010', // 기쁨: 0, 2, 4, 6, 8, 10 + '111000111000', // 삐진: 0, 1, 2, 6, 7, 8 + '010101010101', // 짜릿한: 1, 3, 5, 7, 9, 11 + '010111010111', // 슬픈: 1, 3, 4, 5, 7, 9, 10, 11 + '111111111111', // 놀란: 0~11 + '111101111101', // 졸린: 0, 1, 2, 3, 5, 6, 7, 8, 9, 11 + '101111101111', // 웃는: 0, 2, 3, 4, 5, 6, 8, 9, 10, 11 + '110110011011', // 멍한: 0, 1, 3, 4, 7, 8, 10, 11 + '001110100011', // 속상한: 2, 3, 4, 6, 10, 11 + ]; + const index = Math.max(0, Math.min(patterns.length - 1, Number(expression) - 1)); + return [patterns[index]]; + }; + const paintNeoPattern = (rows, color) => { + let ledIndex = 0; + const colors = []; + rows.forEach((row) => { + String(row) + .split('') + .forEach((on) => { + if (ledIndex < Entry.ANIMALKEYRING.neopixelCount) { + colors.push({ + index: ledIndex, + color: on === '1' ? color : '#000000', + }); + } + ledIndex++; + }); + }); + Entry.ANIMALKEYRING.sendNeoPixelPattern(colors); + }; + const paintNeoBitmap = (value, color) => { + const colors = Entry.ANIMALKEYRING.led2FieldToPixels(value) + .map((brightness, ledIndex) => ({ + index: Entry.ANIMALKEYRING.neopixelOrder[ledIndex], + color: Entry.ANIMALKEYRING.scaleHexColor(color, brightness), + })); + Entry.ANIMALKEYRING.sendNeoPixelPattern(colors); + }; + const playTone = (frequency, seconds) => { + const duration = Math.max( + 0, + Math.min(255, Math.round(Number(seconds) * 10) || 0) + ); + Entry.ANIMALKEYRING.sendProtocol(Entry.ANIMALKEYRING.pins.BUZZER, [ + Entry.ANIMALKEYRING.protocol.device.BUZZER, + duration ? Entry.ANIMALKEYRING.frequencyToNote(frequency) : 0, + duration, + ]); + }; + + return { + // 일반 LED 블록 구분 제목 + makeitall_led_title: title('makeitall_led_title', Lang.template.makeitall_led_title, 'led'), + // 일반 LED를 켜거나 끄는 블록 + makeitall_sensor_led: basic('led', [dropdown([['켜기', '1'], ['끄기', '0']], '0')], + { params: ['1'], type: 'makeitall_sensor_led' }, { VALUE: 0 }, + (sprite, script) => { + Entry.ANIMALKEYRING.writePin(Entry.ANIMALKEYRING.pins.LED, + Entry.ANIMALKEYRING.protocol.device.DIGITAL, script.getField('VALUE', script)); + return script.callReturn(); + }), + // PWM 값으로 일반 LED 밝기를 조절하는 블록 + makeitall_sensor_led_brightness: basic('led', [{ type: 'Block', accept: 'string', value: '128' }], + { params: ['128'], type: 'makeitall_sensor_led_brightness' }, { VALUE: 0 }, + (sprite, script) => { + Entry.ANIMALKEYRING.writePin(Entry.ANIMALKEYRING.pins.LED, + Entry.ANIMALKEYRING.protocol.device.ANALOG, script.getNumberValue('VALUE', script)); + return script.callReturn(); + }), + + // 버튼 입력 블록 구분 제목 + makeitall_input_title: title('makeitall_input_title', Lang.template.makeitall_input_title, 'input'), + // 왼쪽 또는 오른쪽 버튼의 눌림 상태를 읽는 블록 + makeitall_input: Object.assign(basic('input', [dropdown([['왼쪽', '3'], ['오른쪽', '2']], '3')], + { params: ['3'], type: 'makeitall_input' }, { PIN: 0 }, + (sprite, script) => { + const pin = script.getField('PIN', script); + Entry.ANIMALKEYRING.subscribeDigital(pin); + const value = Entry.ANIMALKEYRING.getDigitalPortValue(pin); + if (value !== undefined) return value; + return 0; + }), { skeleton: 'basic_string_field' }), + + // 피에조 부저 블록 구분 제목 + makeitall_piezobuzzer_title: title('makeitall_piezobuzzer_title', Lang.template.makeitall_piezobuzzer_title, 'buzzer'), + // 지정한 음을 설정한 박자 동안 연주하는 블록 + makeitall_sensor_piezobuzzer: basic('buzzer', [ + dropdown(noteOptions, '262'), + { type: 'Block', accept: 'string', value: '0.3' }, + ], { params: ['262', '0.3'], type: 'makeitall_sensor_piezobuzzer' }, { HZ: 0, BEATS: 1 }, + (sprite, script) => { + playTone(script.getNumberValue('HZ', script), script.getNumberValue('BEATS', script)); + return script.callReturn(); + }), + // 짧은 모스부호 점 소리를 출력하는 블록 + makeitall_sensor_morsedot: basic('buzzer', [], { type: 'makeitall_sensor_morsedot' }, {}, + (sprite, script) => { playTone(800, 0.1); return script.callReturn(); }), + // 긴 모스부호 선 소리를 출력하는 블록 + makeitall_sensor_morseline: basic('buzzer', [], { type: 'makeitall_sensor_morseline' }, {}, + (sprite, script) => { playTone(800, 0.3); return script.callReturn(); }), + // 내장 멜로디를 지정한 시간 동안 재생하는 블록 + makeitall_sensor_playmelody: basic('buzzer', [ + { type: 'Block', accept: 'string', value: String(melodyIds.THREE_BEARS) }, + { type: 'Block', accept: 'string', value: '1' }], + { params: [melodyIds.THREE_BEARS, '1'], type: 'makeitall_sensor_playmelody' }, + { MELODY: 0, DURATION: 1 }, + (sprite, script) => { + if (!script.isStart) { + const melody = Math.max( + melodyIds.THREE_BEARS, + Math.min( + melodyIds.URGENT, + Math.round(script.getNumberValue('MELODY', script)) || + melodyIds.THREE_BEARS + ) + ); + const duration = Math.max( + 1, + Math.min(20, Math.round(script.getNumberValue('DURATION', script)) || 1) + ); + script.isStart = true; + script.startedAt = Date.now(); + script.duration = duration * 1000; + Entry.ANIMALKEYRING.sendProtocol(Entry.ANIMALKEYRING.pins.BUZZER, [ + Entry.ANIMALKEYRING.protocol.device.BUZZER, + Entry.ANIMALKEYRING.protocol.action.PLAY_MELODY, + melody, + duration, + ]); + return script; + } + if (Date.now() - script.startedAt < script.duration) { + return script; + } + Entry.ANIMALKEYRING.sendProtocol(Entry.ANIMALKEYRING.pins.BUZZER, [ + Entry.ANIMALKEYRING.protocol.device.BUZZER, + Entry.ANIMALKEYRING.protocol.action.STOP, + ]); + Entry.ANIMALKEYRING.subscribeDigital(2); + Entry.ANIMALKEYRING.subscribeDigital(3); + delete script.isStart; + delete script.startedAt; + delete script.duration; + return script.callReturn(); + }), + // 선택한 내장 소리 번호를 값으로 돌려주는 블록 + makeitall_sensor_box: Object.assign(basic('buzzer', [ + dropdown(buzzerSoundOptions, melodyIds.THREE_BEARS), + ], + { params: [melodyIds.THREE_BEARS], type: 'makeitall_sensor_box' }, { VALUE: 0 }, + (sprite, script) => script.getField('VALUE', script)), { skeleton: 'basic_string_field' }), + + // 네오픽셀 블록 구분 제목 + makeitall_neo_title: title('makeitall_neo_title', Lang.template.makeitall_neo_title, 'neopixel'), + // 표정 모양을 색상 선택기로 출력하는 블록 + makeitall_neo_exression_rainbowcolor: basic('neopixel', [dropdown(expressions, '1'), { type: 'Color' }], + { params: ['1', null], type: 'makeitall_neo_exression_rainbowcolor' }, { EXPRESSION: 0, COLOR: 1 }, + (sprite, script) => { + paintNeoPattern(expressionRows(script.getField('EXPRESSION', script)), script.getStringField('COLOR', script)); + return script.callReturn(); + }), + // 표정 모양을 미리 정의된 색상으로 출력하는 블록 + makeitall_neo_expression_color: basic('neopixel', [dropdown(expressions, '1'), dropdown(colorOptions, '#FF0000')], + { params: ['1', '#FF0000'], type: 'makeitall_neo_expression_color' }, { EXPRESSION: 0, COLOR: 1 }, + (sprite, script) => { + paintNeoPattern(expressionRows(script.getField('EXPRESSION', script)), script.getField('COLOR', script)); + return script.callReturn(); + }), + // 12개 LED의 개별 밝기 모양을 색상 선택기로 출력하는 블록 + makeitall_neo_bitmap_rainbowcolor: basic('neopixel', [ + { type: 'AnimalLed', rows: 6, columns: 2 }, + { type: 'Color' }, + ], + { params: [Entry.ANIMALKEYRING.neopixelDefaultLed, null], type: 'makeitall_neo_bitmap_rainbowcolor' }, + { VALUE: 0, COLOR: 1 }, (sprite, script) => { + paintNeoBitmap( + script.getField('VALUE'), + script.getStringField('COLOR') + ); + return script.callReturn(); + }), + // 12개 LED의 개별 밝기 모양을 미리 정의된 색상으로 출력하는 블록 + makeitall_neo_bitmap_color: basic('neopixel', [ + { type: 'AnimalLed', rows: 6, columns: 2 }, + dropdown(colorOptions, '#FF0000'), + ], + { params: [Entry.ANIMALKEYRING.neopixelDefaultLed, '#FF0000'], type: 'makeitall_neo_bitmap_color' }, + { VALUE: 0, COLOR: 1 }, (sprite, script) => { + paintNeoBitmap( + script.getField('VALUE'), + script.getField('COLOR') + ); + return script.callReturn(); + }), + // 모든 네오픽셀 LED를 끄는 블록 + makeitall_neo_clear: basic('neopixel', [], { type: 'makeitall_neo_clear' }, {}, + (sprite, script) => { Entry.ANIMALKEYRING.clearNeoPixel(); return script.callReturn(); }), + + // 도트매트릭스 블록 구분 제목 + makeitall_dotmatrix_title: title('makeitall_dotmatrix_title', Lang.template.makeitall_dotmatrix_title, 'dotmatrix'), + // 사용자가 그린 8x8 비트맵을 출력하는 블록 + makeitall_dotmatrix_bitmap: basic('dotmatrix', [ + { type: 'AnimalLed', rows: 8, columns: 8 }, + { type: 'Color' }, + ], + { params: [Entry.ANIMALKEYRING.dotMatrixDefaultLed, null], type: 'makeitall_dotmatrix_bitmap' }, + { VALUE: 0, COLOR: 1 }, (sprite, script) => { + const bitmap = script.getField('VALUE'); + Entry.ANIMALKEYRING.sendDotMatrixRows( + Entry.ANIMALKEYRING.ledFieldToRows(bitmap, 8) + ); + return script.callReturn(); + }), + // 미리 정의된 8x8 아이콘을 출력하는 블록 + makeitall_dotmatrix: basic('dotmatrix', [dropdown(dotIcons, dotIcons[0][1])], + { params: [dotIcons[0][1]], type: 'makeitall_dotmatrix' }, { VALUE: 0 }, + (sprite, script) => { + Entry.ANIMALKEYRING.sendDotMatrixRows(Entry.ANIMALKEYRING.patternStringToRows(script.getField('VALUE', script))); + return script.callReturn(); + }), + // 8x8 도트매트릭스 화면을 지우는 블록 + makeitall_dotmatrix_clear: basic('dotmatrix', [], { type: 'makeitall_dotmatrix_clear' }, {}, + (sprite, script) => { + Entry.ANIMALKEYRING.sendDotMatrixRows(Array(8).fill(0)); + return script.callReturn(); + }), + }; + } +})(); + +module.exports = Entry.ANIMALKEYRING; diff --git a/src/playground/blocks/hardware/block_jikko_basic.js b/src/playground/blocks/hardware/block_jikko_basic.js index e735004406..f986516b79 100644 --- a/src/playground/blocks/hardware/block_jikko_basic.js +++ b/src/playground/blocks/hardware/block_jikko_basic.js @@ -15,7 +15,11 @@ Entry.jikko_basic = { //정지시 초기화 함수 setZero: function() { - if (!Entry.hw.sendQueue.SET) { + if ( + !Entry.hw.sendQueue.SET || + typeof Entry.hw.sendQueue.SET !== 'object' || + Array.isArray(Entry.hw.sendQueue.SET) + ) { Entry.hw.sendQueue = { GET: {}, SET: {}, @@ -23,17 +27,33 @@ Entry.jikko_basic = { } else { var keySet = Object.keys(Entry.hw.sendQueue.SET); keySet.forEach((key) => { - if (Entry.hw.sendQueue.SET[key].type == Entry.jikko_basic.sensorTypes.SERVO) { - Entry.hw.sendQueue.SET[key].data = 200; - Entry.hw.sendQueue.SET[key].time = new Date().getTime(); + var command = Entry.hw.sendQueue.SET[key]; + + // 다른 하드웨어가 사용하는 단일 패킷 형식(protocol, packet 등)은 + // jikko_basic의 핀별 명령이 아니므로 정지 처리 대상에서 제외한다. + if ( + !command || + typeof command !== 'object' || + Array.isArray(command) || + !Object.prototype.hasOwnProperty.call(command, 'type') + ) { + delete Entry.hw.sendQueue.SET[key]; + return; + } + + if (command.type == Entry.jikko_basic.sensorTypes.SERVO) { + command.data = 200; + command.time = new Date().getTime(); } else if ( - Entry.hw.sendQueue.SET[key].type == Entry.jikko_basic.sensorTypes.SERVO2 + command.type == Entry.jikko_basic.sensorTypes.SERVO2 ) { - Entry.hw.sendQueue.SET[key].data.value1 = 200; - Entry.hw.sendQueue.SET[key].time = new Date().getTime(); + if (command.data && typeof command.data === 'object') { + command.data.value1 = 200; + } + command.time = new Date().getTime(); } else { - Entry.hw.sendQueue.SET[key].data = 0; - Entry.hw.sendQueue.SET[key].time = new Date().getTime(); + command.data = 0; + command.time = new Date().getTime(); } }); } @@ -112,6 +132,15 @@ Entry.jikko_basic.setLanguage = function() { return { ko: { template: { + jikko_basic_pin_title: '핀', + jikko_basic_input_title: '입력', + jikko_basic_led_title: 'LED', + jikko_basic_motor_title: '모터', + jikko_basic_piezobuzzer_title: '피에조부저', + jikko_basic_mp3_title: 'MP3', + jikko_basic_lcd_title: 'I2C LCD', + jikko_basic_neopixel_title: '네오픽셀', + jikko_basic_dotmatrix_title: '도트매트릭스', jikko_basic_toggle_on: '켜기', jikko_basic_toggle_off: '끄기', jikko_basic_lcd_first_line: '첫 번째', @@ -172,6 +201,15 @@ Entry.jikko_basic.setLanguage = function() { }, en: { template: { + jikko_basic_pin_title: 'Pin', + jikko_basic_input_title: 'Input', + jikko_basic_led_title: 'LED', + jikko_basic_motor_title: 'Motor', + jikko_basic_piezobuzzer_title: 'Piezo buzzer', + jikko_basic_mp3_title: 'MP3', + jikko_basic_lcd_title: 'I2C LCD', + jikko_basic_neopixel_title: 'NeoPixel', + jikko_basic_dotmatrix_title: 'Dot matrix', jikko_basic_toggle_on: 'on', jikko_basic_toggle_off: 'off', jikko_basic_lcd_first_line: 'first', @@ -224,16 +262,25 @@ Entry.jikko_basic.setLanguage = function() { }; }; Entry.jikko_basic.blockMenuBlocks = [ + 'jikko_basic_pin_title', 'jikko_basic_set_digital_toggle', - 'jikko_basic_get_analog_value', 'jikko_basic_get_digital', + 'jikko_basic_get_analog_value', 'jikko_basic_get_analog_mapping', 'jikko_basic_mapping1', 'jikko_basic_mapping2', + 'jikko_basic_led_title', 'jikko_basic_set_led_toggle', 'jikko_basic_set_digital_pwm', + 'jikko_basic_motor_title', + 'jikko_basic_set_digital_dcmotor', + 'jikko_basic_set_analog_dcmotor', + 'jikko_basic_set_digital_servo', + 'jikko_basic_set_digital_servo2', + + 'jikko_basic_input_title', 'jikko_basic_get_digital_ultrasonic', 'jikko_basic_get_digital_toggle', 'jikko_basic_get_light_value', @@ -242,32 +289,35 @@ Entry.jikko_basic.blockMenuBlocks = [ 'jikko_basic_get_pullup', 'jikko_basic_get_button', - 'jikko_basic_set_digital_dcmotor', - 'jikko_basic_set_analog_dcmotor', - 'jikko_basic_set_digital_servo', - 'jikko_basic_set_digital_servo2', + 'jikko_basic_piezobuzzer_title', 'jikko_basic_set_digital_buzzer_toggle', 'jikko_basic_set_digital_buzzer_volume', 'jikko_basic_set_digital_buzzer', + + 'jikko_basic_mp3_title', + 'jikko_basic_set_mp3_init', + 'jikko_basic_set_mp3_vol', + 'jikko_basic_set_mp3_play', + 'jikko_basic_set_mp3_play2', + + 'jikko_basic_lcd_title', + 'jikko_basic_lcd_init', + 'jikko_basic_module_digital_lcd', + 'jikko_basic_lcd_clear', + + 'jikko_basic_neopixel_title', 'jikko_basic_set_neopixel_init', 'jikko_basic_set_neopixel_bright', 'jikko_basic_set_neopixel', 'jikko_basic_set_neopixel_all', 'jikko_basic_set_neopixel_clear', + + 'jikko_basic_dotmatrix_title', 'jikko_basic_set_dotmatrix_init', 'jikko_basic_set_dotmatrix_bright', 'jikko_basic_set_dotmatrix', 'jikko_basic_set_dotmatrix_emoji', 'jikko_basic_set_dotmatrix_clear', - 'jikko_basic_lcd_init', - 'jikko_basic_module_digital_lcd', - 'jikko_basic_get_lcd_row', - 'jikko_basic_get_lcd_col', - 'jikko_basic_lcd_clear', - 'jikko_basic_set_mp3_init', - 'jikko_basic_set_mp3_vol', - 'jikko_basic_set_mp3_play', - 'jikko_basic_set_mp3_play2', // 'jikko_basic_get_digital_bluetooth', // 'jikko_basic_module_digital_bluetooth', @@ -275,8 +325,66 @@ Entry.jikko_basic.blockMenuBlocks = [ Entry.jikko_basic.getBlocks = function() { var tx; var din; + var title = function(blockName, template, className) { + return { + skeleton: 'basic_text', + color: EntryStatic.colorSet.common.TRANSPARENT, + fontColor: '#333333', + skeletonOptions: { contentPos: { x: 10, y: 10 } }, + params: [{ type: 'Text', text: template, color: '#333333', align: 'left' }], + def: { type: blockName }, + isNotFor: ['jikko_basic'], + class: className, + fontSize: 22, + }; + }; - return { + var blocks = { + jikko_basic_pin_title: title( + 'jikko_basic_pin_title', + Lang.template.jikko_basic_pin_title, + 'jikko_basicPin' + ), + jikko_basic_input_title: title( + 'jikko_basic_input_title', + Lang.template.jikko_basic_input_title, + 'jikko_basicGet' + ), + jikko_basic_led_title: title( + 'jikko_basic_led_title', + Lang.template.jikko_basic_led_title, + 'jikko_basicLed' + ), + jikko_basic_motor_title: title( + 'jikko_basic_motor_title', + Lang.template.jikko_basic_motor_title, + 'jikko_basicSet' + ), + jikko_basic_piezobuzzer_title: title( + 'jikko_basic_piezobuzzer_title', + Lang.template.jikko_basic_piezobuzzer_title, + 'jikko_basicBuzzer' + ), + jikko_basic_mp3_title: title( + 'jikko_basic_mp3_title', + Lang.template.jikko_basic_mp3_title, + 'mp3' + ), + jikko_basic_lcd_title: title( + 'jikko_basic_lcd_title', + Lang.template.jikko_basic_lcd_title, + 'jikko_basicModule' + ), + jikko_basic_neopixel_title: title( + 'jikko_basic_neopixel_title', + Lang.template.jikko_basic_neopixel_title, + 'neo' + ), + jikko_basic_dotmatrix_title: title( + 'jikko_basic_dotmatrix_title', + Lang.template.jikko_basic_dotmatrix_title, + 'dot' + ), jikko_basic_list_analog_basic: { color: EntryStatic.colorSet.block.default.HARDWARE, outerLine: EntryStatic.colorSet.block.darken.HARDWARE, @@ -526,8 +634,8 @@ Entry.jikko_basic.getBlocks = function() { }, jikko_basic_set_neopixel_init: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#FF9800', + outerLine: '#D97D00', skeleton: 'basic', statements: [], params: [ @@ -604,8 +712,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_neopixel_bright: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#FF9800', + outerLine: '#D97D00', skeleton: 'basic', statements: [], params: [ @@ -688,8 +796,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_neopixel: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#FF9800', + outerLine: '#D97D00', skeleton: 'basic', statements: [], params: [ @@ -795,8 +903,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_neopixel_all: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#FF9800', + outerLine: '#D97D00', skeleton: 'basic', statements: [], params: [ @@ -888,8 +996,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_neopixel_clear: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#FF9800', + outerLine: '#D97D00', skeleton: 'basic', statements: [], params: [ @@ -1385,8 +1493,8 @@ Entry.jikko_basic.getBlocks = function() { }, jikko_basic_set_dotmatrix_init: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#6D4C41', + outerLine: '#51362F', skeleton: 'basic', statements: [], params: [ @@ -1484,8 +1592,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_dotmatrix_bright: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#6D4C41', + outerLine: '#51362F', skeleton: 'basic', statements: [], params: [ @@ -1557,8 +1665,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_dotmatrix_clear: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#6D4C41', + outerLine: '#51362F', skeleton: 'basic', statements: [], params: [ @@ -1611,8 +1719,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_dotmatrix: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#6D4C41', + outerLine: '#51362F', skeleton: 'basic', statements: [], params: [ @@ -1718,8 +1826,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_set_dotmatrix_emoji: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#6D4C41', + outerLine: '#51362F', skeleton: 'basic', statements: [], params: [ @@ -3367,8 +3475,8 @@ Entry.jikko_basic.getBlocks = function() { }, }, jikko_basic_get_dht: { - color: EntryStatic.colorSet.block.default.HARDWARE, - outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + color: '#5254DC', + outerLine: '#3739B8', fontColor: '#fff', skeleton: 'basic_string_field', statements: [], @@ -3752,6 +3860,111 @@ Entry.jikko_basic.getBlocks = function() { }, }, }; + + var categoryColors = { + jikko_basicPin: { color: '#EF3F4A', dark: '#CC2835' }, + jikko_basicLed: { color: '#DF3075', dark: '#B91F5B' }, + jikko_basicSet: { color: '#A944D4', dark: '#872DB0' }, + jikko_basicGet: { color: '#5254DC', dark: '#3739B8' }, + jikko_basicBuzzer: { color: '#315EEA', dark: '#2144BD' }, + mp3: { color: '#169CB0', dark: '#0B7A8B' }, + jikko_basicModule: { color: '#0DB27B', dark: '#07865B' }, + neo: { color: '#FF9800', dark: '#D97D00' }, + dot: { color: '#6D4C41', dark: '#51362F' }, + }; + var forcedCategoryByType = { + jikko_basic_get_dht: 'jikko_basicGet', + jikko_basic_set_neopixel_init: 'neo', + jikko_basic_set_neopixel_bright: 'neo', + jikko_basic_set_neopixel: 'neo', + jikko_basic_set_neopixel_all: 'neo', + jikko_basic_set_neopixel_clear: 'neo', + jikko_basic_set_dotmatrix_init: 'dot', + jikko_basic_set_dotmatrix_bright: 'dot', + jikko_basic_set_dotmatrix: 'dot', + jikko_basic_set_dotmatrix_emoji: 'dot', + jikko_basic_set_dotmatrix_clear: 'dot', + }; + var getCategoryColors = function(type, block) { + return categoryColors[forcedCategoryByType[type] || (block && block.class)]; + }; + var applyParamColors = function(params, colors) { + (params || []).forEach(function(param) { + if (!param || typeof param !== 'object') { + return; + } + if (param.bgColor) { + param.bgColor = colors.dark; + } + if (param.arrowColor) { + param.arrowColor = '#FFFFFF'; + } + }); + }; + + // LCD 열·행 선택 블록은 LCD에서만 사용하므로 타입을 복제하지 않고 직접 색상을 상속한다. + blocks.jikko_basic_get_lcd_col.class = 'jikko_basicModule'; + blocks.jikko_basic_get_lcd_row.class = 'jikko_basicModule'; + + // 핀, 음계처럼 여러 카테고리가 함께 사용하던 값 블록을 카테고리별로 복제한다. + // 부모 블록의 색을 따라가게 하면서 기존 값 블록 타입은 호환성을 위해 유지한다. + Object.keys(blocks).forEach(function(type) { + var block = blocks[type]; + var colors = getCategoryColors(type, block); + var defParams = block && block.def && block.def.params; + if (!colors || !Array.isArray(defParams) || block.skeleton === 'basic_text') { + return; + } + + defParams.forEach(function(param) { + var sourceType = param && param.type; + if ( + sourceType === 'jikko_basic_get_lcd_col' || + sourceType === 'jikko_basic_get_lcd_row' + ) { + return; + } + var sourceBlock = + sourceType && + (blocks[sourceType] || + (sourceType === 'arduino_get_port_number' + ? blocks.jikko_basic_list_digital_basic + : Entry.block[sourceType])); + if (!sourceBlock || sourceBlock.skeleton === 'basic_text') { + return; + } + + var coloredType = sourceType + '__' + block.class; + if (!blocks[coloredType]) { + blocks[coloredType] = Object.assign({}, sourceBlock, { + color: colors.color, + outerLine: colors.dark, + class: block.class, + params: (sourceBlock.params || []).map(function(sourceParam) { + return Object.assign({}, sourceParam, { + bgColor: sourceParam.bgColor ? colors.dark : sourceParam.bgColor, + arrowColor: sourceParam.arrowColor ? '#FFFFFF' : sourceParam.arrowColor, + }); + }), + def: Object.assign({}, sourceBlock.def, { type: coloredType }), + }); + } + param.type = coloredType; + }); + }); + + Object.keys(blocks).forEach(function(type) { + var block = blocks[type]; + var colors = getCategoryColors(type, block); + if (!colors || block.skeleton === 'basic_text') { + return; + } + block.color = colors.color; + block.outerLine = colors.dark; + applyParamColors(block.params, colors); + }); + + return blocks; }; module.exports = Entry.jikko_basic; diff --git a/src/playground/blocks/hardware/block_jikko_darae.js b/src/playground/blocks/hardware/block_jikko_darae.js new file mode 100644 index 0000000000..1ddf4c319e --- /dev/null +++ b/src/playground/blocks/hardware/block_jikko_darae.js @@ -0,0 +1,911 @@ +'use strict'; + +/** + * 직코 다래 EntryJS 하드웨어 블록 모듈 + * + * 명령 전송 흐름: + * 엔트리 블록 -> sendProtocol() -> Entry.hw.sendQueue.SET + * -> Entry Hardware -> 다래보드 펌웨어 + * + * 데이터 수신 흐름: + * 다래보드 펌웨어 -> Entry Hardware -> Entry.hw.portData + * -> afterReceive() 및 값 읽기 블록 + */ +Entry.JIKKO_DARAE = new (class JIKKO_DARAE { + // 하드웨어 정보, 통신 규격, 핀 설정과 내부 상태를 준비한다. + constructor() { + this.id = '47.6'; + this.name = 'JIKKO_DARAE'; + this.url = 'https://www.makeitall.co.kr/'; + this.imageName = 'jikko.png'; + this.title = { ko: '직코 다래보드', en: 'JIKKO DARAE' }; + + // 통신 프로토콜 V2.0.1 정의 + this.protocol = { + version: '2.0.1', + HEADER_1: 0xff, + HEADER_2: 0xfd, + instruction: { + READ_MASK: 0x40, + ETC: 0xff, + }, + device: { + DIGITAL: 0x01, + ANALOG: 0x02, + SUBSCRIBE: 0x03, + NEOPIXEL: 0x04, + GYRO: 0x0e, + OPTICAL: 0x0f, + IR_DISTANCE: 0x10, + }, + action: { + CLEAR: 0x02, + OUTPUT: 0x05, + }, + }; + + // 보드별 고정 배선 정보를 통신 프로토콜과 분리해 관리한다. + this.pins = { + LED: 13, + NEOPIXEL: 23, + MOTOR: [5, 6, 9, 10], + }; + this.sensorSubscriptions = {}; + this.sensorRequestTimes = {}; + this.sensorRequestIntervalMs = 100; + this.protocolSendQueue = []; + this.protocolSendTimer = null; + this.protocolSendSequence = 0; + this.protocolSendIntervalMs = 40; + this.rgbUsed = false; + this.zeroOutputSent = false; + this.blockMenuBlocks = [ + 'jikko_darae_led_title', + 'jikko_darae_led', + 'jikko_darae_led_brightness', + 'jikko_darae_rgb_color', + 'jikko_darae_rgb_clear', + 'jikko_darae_input_title', + 'jikko_darae_switch_pull_down', + 'jikko_darae_switch_pull_up', + 'jikko_darae_variable_resistor', + 'jikko_darae_gyro_acceleration', + 'jikko_darae_gyro_angle', + 'jikko_darae_gyro_temperature', + 'jikko_darae_optical', + 'jikko_darae_ir_distance', + 'jikko_darae_motor_title', + 'jikko_darae_dc_motor_power', + 'jikko_darae_dc_motor_switch', + 'jikko_darae_wifi_title', + 'jikko_darae_wifi_roll', + 'jikko_darae_wifi_pitch', + 'jikko_darae_wifi_yaw', + 'jikko_darae_wifi_throttle', + 'jikko_darae_wifi_arming', + 'jikko_darae_wifi_rgb_pin', + 'jikko_darae_wifi_rgb_r', + 'jikko_darae_wifi_rgb_g', + 'jikko_darae_wifi_rgb_b', + ]; + } + + // ------------------------------------------------------------------------- + // FF FD 프로토콜 패킷 생성 + // 블록 값을 바이트로 정리하고 CRC-16/MODBUS를 붙인다. + // ------------------------------------------------------------------------- + clampByte(value) { + return Math.max(0, Math.min(255, Math.round(Number(value) || 0))); + } + + /** + * CRC-16/MODBUS 체크섬을 계산한다. + * 명령 바이트부터 마지막 매개변수까지를 계산 범위로 사용한다. + */ + calculateCrc(bytes) { + let crc = 0xffff; + bytes.forEach((value) => { + crc ^= this.clampByte(value); + for (let bit = 0; bit < 8; bit++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xa001 : crc >>> 1; + } + }); + return crc & 0xffff; + } + + /** + * 패킷 구성: FF FD | 매개변수 길이 | 명령 | 매개변수... | CRC 하위 | CRC 상위 + */ + buildPacket(instruction, parameters = []) { + const normalizedInstruction = this.clampByte(instruction); + const normalizedParameters = parameters.map((value) => this.clampByte(value)); + const crc = this.calculateCrc([normalizedInstruction].concat(normalizedParameters)); + + return [ + this.protocol.HEADER_1, + this.protocol.HEADER_2, + normalizedParameters.length, + normalizedInstruction, + ].concat(normalizedParameters, [crc & 0xff, (crc >>> 8) & 0xff]); + } + + // ------------------------------------------------------------------------- + // EntryJS -> Entry Hardware 전송 + // 연속된 블록 명령이 덮어써지지 않도록 큐에서 순서대로 전달한다. + // ------------------------------------------------------------------------- + flushProtocolQueue() { + if (!this.protocolSendQueue.length) { + this.protocolSendTimer = null; + return; + } + const command = this.protocolSendQueue.shift(); + Entry.hw.sendQueue = Entry.hw.sendQueue || {}; + Entry.hw.sendQueue.SET = { + protocol: this.protocol.version, + instruction: command.instruction, + parameters: command.parameters, + packet: command.packet, + time: command.time, + }; + Entry.hw.update(); + + this.protocolSendTimer = setTimeout( + () => this.flushProtocolQueue(), + this.protocolSendIntervalMs + ); + } + + // 명령을 패킷으로 만든 뒤 Entry Hardware 전송 대기열에 추가한다. + sendProtocol(instruction, parameters = [], options = {}) { + this.zeroOutputSent = false; + const normalizedInstruction = this.clampByte(instruction); + const normalizedParameters = parameters.map((value) => this.clampByte(value)); + const packet = this.buildPacket(normalizedInstruction, normalizedParameters); + this.protocolSendSequence += 1; + this.protocolSendQueue.push({ + instruction: normalizedInstruction, + parameters: normalizedParameters, + packet, + time: options.time || Date.now() * 1000 + (this.protocolSendSequence % 1000), + }); + if (!this.protocolSendTimer) { + this.flushProtocolQueue(); + } + return packet; + } + + writePin(pin, device, value) { + return this.sendProtocol(pin, [device, value]); + } + + readPin(pin, device, parameters = []) { + return this.sendProtocol( + this.protocol.instruction.READ_MASK + this.clampByte(pin), + [device].concat(parameters) + ); + } + + subscribe(pin, device, parameters = []) { + const normalizedPin = this.clampByte(pin); + const key = `${device}:${normalizedPin}:${parameters.join(',')}`; + const now = Date.now(); + if ( + this.sensorRequestTimes[key] && + now - this.sensorRequestTimes[key] < this.sensorRequestIntervalMs + ) { + return; + } + this.sensorRequestTimes[key] = now; + return this.readPin(normalizedPin, device, parameters); + } + + subscribeDigital(pin, pullUp = false) { + return this.subscribe( + this.entryPinToFirmwarePin(pin), + this.protocol.device.DIGITAL, + [pullUp ? 1 : 0] + ); + } + + subscribeExtended(device) { + const key = `extended:${device}`; + if (this.sensorSubscriptions[key]) { + return; + } + this.sensorSubscriptions[key] = true; + return this.sendProtocol(this.protocol.instruction.ETC, [ + device, + this.protocol.device.SUBSCRIBE, + ]); + } + + unsubscribeExtendedSensors() { + Object.keys(this.sensorSubscriptions).forEach((key) => { + if (!key.startsWith('extended:')) { + return; + } + + const device = Number(key.substring('extended:'.length)); + if (!Number.isFinite(device)) { + return; + } + + this.sendProtocol(this.protocol.instruction.ETC, [ + device, + this.protocol.action.CLEAR, + ]); + }); + } + + entryPinToFirmwarePin(pin) { + const number = Number(pin); + if (number === 32) { + return 2; + } + if (number === 33) { + return 3; + } + return this.clampByte(number); + } + + // Entry Hardware가 전달한 portData에서 센서 및 Wi-Fi 값을 가져온다. + getFirstPortValue(keys, fallback = 0) { + const portData = Entry.hw.portData || {}; + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + if (portData[key] !== undefined) { + const value = portData[key]; + return value && value.value !== undefined ? value.value : value; + } + } + return fallback; + } + + hexToRgb555(color) { + const value = String(color || '#000000').replace('#', ''); + const red = parseInt(value.slice(0, 2), 16) || 0; + const green = parseInt(value.slice(2, 4), 16) || 0; + const blue = parseInt(value.slice(4, 6), 16) || 0; + return ((red >>> 3) << 10) | ((green >>> 3) << 5) | (blue >>> 3); + } + + setRgbLed(position, color) { + const rgb555 = this.hexToRgb555(color); + const index = position === 'right' ? 0 : 1; + this.rgbUsed = true; + return this.sendProtocol(this.pins.NEOPIXEL, [ + this.protocol.device.NEOPIXEL, + index, + rgb555 & 0xff, + (rgb555 >>> 8) & 0xff, + ]); + } + + // ------------------------------------------------------------------------- + // 작품 정지 및 하드웨어 초기화 + // 대기 명령과 센서 구독을 정리하고 모든 출력을 안전하게 끈다. + // ------------------------------------------------------------------------- + setZero() { + if (this.zeroOutputSent) { + return; + } + + // 정지 전에 쌓인 명령을 제거해 모터 명령이 뒤늦게 전송되지 않게 한다. + if (this.protocolSendTimer) { + clearTimeout(this.protocolSendTimer); + this.protocolSendTimer = null; + } + this.protocolSendQueue = []; + + // 펌웨어의 연속 센서 전송을 중단한 뒤 로컬 구독 정보도 초기화한다. + this.unsubscribeExtendedSensors(); + this.sensorSubscriptions = {}; + this.sensorRequestTimes = {}; + + this.pins.MOTOR.forEach((pin) => { + this.writePin(pin, this.protocol.device.ANALOG, 0); + }); + this.writePin(this.pins.LED, this.protocol.device.DIGITAL, 0); + if (this.rgbUsed) { + this.sendProtocol(this.pins.NEOPIXEL, [ + this.protocol.device.NEOPIXEL, + this.protocol.action.CLEAR, + ]); + this.rgbUsed = false; + } + this.zeroOutputSent = true; + } + + // 블록 화면에 표시할 한국어와 영어 문구를 정의한다. + setLanguage() { + return { + ko: { + template: { + jikko_darae_led_title: 'LED', + jikko_darae_led: 'LED %1 핀 %2', + jikko_darae_led_brightness: 'LED ( %1 핀) 밝기 %2 출력 (0~255)', + jikko_darae_rgb_color: 'RGB LED %1 %2 색 출력', + jikko_darae_rgb_clear: 'RGB LED 모든 LED 끄기', + jikko_darae_input_title: '입력', + jikko_darae_switch_pull_down: '스위치 %1 핀 눌림 상태(풀다운)', + jikko_darae_switch_pull_up: '스위치 %1 핀 눌림 상태(풀업)', + jikko_darae_variable_resistor: '가변저항 %1 핀 값', + jikko_darae_gyro_acceleration: '자이로센서 %1 %2 축 값', + jikko_darae_gyro_angle: '자이로센서 각도 %1 축 값', + jikko_darae_gyro_temperature: '자이로센서 온도 값', + jikko_darae_optical: '옵티컬 센서 %1 축 감지값', + jikko_darae_ir_distance: 'IR 센서 거리(mm) 값', + jikko_darae_motor_title: '모터', + jikko_darae_dc_motor_power: 'DC 모터 ( %1 핀) 세기 %2 출력 (0~5)', + jikko_darae_dc_motor_switch: 'DC 모터 %1 핀 %2', + jikko_darae_wifi_title: '와이파이', + jikko_darae_wifi_roll: 'Roll(롤) 값 읽기', + jikko_darae_wifi_pitch: 'Pitch(피치) 값 읽기', + jikko_darae_wifi_yaw: 'Yaw(요) 값 읽기', + jikko_darae_wifi_throttle: 'Throttle(쓰로틀) 값 읽기', + jikko_darae_wifi_arming: '시동 값 읽기', + jikko_darae_wifi_rgb_pin: '조종기 RGB 핀 값 읽기', + jikko_darae_wifi_rgb_r: '조종기 R 값 읽기', + jikko_darae_wifi_rgb_g: '조종기 G 값 읽기', + jikko_darae_wifi_rgb_b: '조종기 B 값 읽기', + }, + }, + en: { + template: { + jikko_darae_led_title: 'LED', + jikko_darae_led: 'LED pin %1 %2', + jikko_darae_led_brightness: 'LED pin %1 brightness %2 (0-255)', + jikko_darae_rgb_color: 'Set %1 RGB LED to %2', + jikko_darae_rgb_clear: 'Turn off all RGB LEDs', + jikko_darae_input_title: 'Input', + jikko_darae_switch_pull_down: 'Switch pin %1 pressed (pull-down)', + jikko_darae_switch_pull_up: 'Switch pin %1 pressed (pull-up)', + jikko_darae_variable_resistor: 'Variable resistor %1 value', + jikko_darae_gyro_acceleration: 'Gyroscope %1 %2-axis value', + jikko_darae_gyro_angle: 'Gyroscope angle %1-axis value', + jikko_darae_gyro_temperature: 'Gyroscope temperature', + jikko_darae_optical: 'Optical sensor %1-axis value', + jikko_darae_ir_distance: 'IR distance (mm)', + jikko_darae_motor_title: 'Motor', + jikko_darae_dc_motor_power: 'DC motor pin %1 power %2 (0-5)', + jikko_darae_dc_motor_switch: 'DC motor pin %1 %2', + jikko_darae_wifi_title: 'Wi-Fi', + jikko_darae_wifi_roll: 'Read Roll', + jikko_darae_wifi_pitch: 'Read Pitch', + jikko_darae_wifi_yaw: 'Read Yaw', + jikko_darae_wifi_throttle: 'Read Throttle', + jikko_darae_wifi_arming: 'Read arming value', + jikko_darae_wifi_rgb_pin: 'Read controller RGB pin', + jikko_darae_wifi_rgb_r: 'Read controller R', + jikko_darae_wifi_rgb_g: 'Read controller G', + jikko_darae_wifi_rgb_b: 'Read controller B', + }, + }, + }; + } + + // ------------------------------------------------------------------------- + // 엔트리 블록 정의 + // 각 블록의 모양, 입력값, 실행 함수와 하드웨어 명령을 연결한다. + // ------------------------------------------------------------------------- + getBlocks() { + const color = EntryStatic.colorSet.block.default.HARDWARE; + const dark = EntryStatic.colorSet.block.darken.HARDWARE; + const arrow = EntryStatic.colorSet.arrow.default.HARDWARE; + const indicator = { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }; + const dropdown = (options, value) => ({ + type: 'Dropdown', + options, + value, + fontSize: 11, + bgColor: dark, + arrowColor: arrow, + }); + const pinOptions = Array.from({ length: 26 }, (_, index) => { + const pin = index + 2; + return [String(pin), String(pin)]; + }); + const digitalPinOptions = ['8', '11', '12', '13'].map((pin) => [pin, pin]); + const motorPinOptions = ['5', '6', '9', '10'].map((pin) => [pin, pin]); + const variableResistorPinOptions = [['A1', '1']]; + const axisOptions = [ + ['X', 'X'], + ['Y', 'Y'], + ['Z', 'Z'], + ]; + const twoAxisOptions = axisOptions.slice(0, 2); + const base = (skeleton, params, def, paramsKeyMap, func) => ({ + color, + outerLine: dark, + fontColor: '#ffffff', + skeleton, + statements: [], + params: params.concat(indicator), + def, + paramsKeyMap, + class: 'jikko_darae', + isNotFor: ['JIKKO_DARAE'], + func, + }); + const command = (params, def, paramsKeyMap, func) => + base('basic', params, def, paramsKeyMap, func); + const valueBlock = (params, def, paramsKeyMap, func) => + base( + 'basic_string_field', + params, + def, + paramsKeyMap, + func + ); + const title = (type) => ({ + skeleton: 'basic_text', + color: EntryStatic.colorSet.common.TRANSPARENT, + fontColor: '#333333', + skeletonOptions: { contentPos: { x: 10, y: 10 } }, + params: [ + { + type: 'Text', + text: Lang.template[type], + color: '#333333', + align: 'left', + }, + ], + def: { type }, + class: 'jikko_darae', + isNotFor: ['JIKKO_DARAE'], + fontSize: 22, + }); + const readDigital = (script, pullUp) => { + const pin = script.getNumberValue('PIN', script); + Entry.JIKKO_DARAE.subscribeDigital(pin, pullUp); + const raw = Entry.JIKKO_DARAE.getFirstPortValue( + [pin, String(pin), `digital_${pin}`], + pullUp ? 1 : 0 + ); + return pullUp ? Number(raw) === 0 : Number(raw) !== 0; + }; + + const blocks = { + // LED 블록 구분 제목 + jikko_darae_led_title: title('jikko_darae_led_title'), + // 디지털 LED를 켜거나 끄는 블록 + jikko_darae_led: command( + [ + dropdown(digitalPinOptions, '13'), + dropdown( + [ + ['켜기', '1'], + ['끄기', '0'], + ], + '1' + ), + ], + { + params: ['13', '1'], + type: 'jikko_darae_led', + }, + { + PIN: 0, + VALUE: 1, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.writePin( + script.getNumberValue('PIN', script), + Entry.JIKKO_DARAE.protocol.device.DIGITAL, + script.getNumberValue('VALUE', script) + ); + return script.callReturn(); + } + ), + // PWM 값으로 일반 LED의 밝기를 조절하는 블록 + jikko_darae_led_brightness: command( + [ + dropdown(digitalPinOptions, '13'), + { type: 'Block', accept: 'string', value: '100' }, + ], + { + params: ['13', '100'], + type: 'jikko_darae_led_brightness', + }, + { + PIN: 0, + VALUE: 1, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.writePin( + script.getNumberValue('PIN', script), + Entry.JIKKO_DARAE.protocol.device.ANALOG, + Entry.JIKKO_DARAE.clampByte(script.getNumberValue('VALUE', script)) + ); + return script.callReturn(); + } + ), + // 왼쪽 또는 오른쪽 RGB LED의 색상을 지정하는 블록 + jikko_darae_rgb_color: command( + [ + dropdown( + [ + ['왼쪽', 'left'], + ['오른쪽', 'right'], + ], + 'left' + ), + { type: 'Color', value: '#44c767' }, + ], + { + params: ['left', '#44c767'], + type: 'jikko_darae_rgb_color', + }, + { + POSITION: 0, + COLOR: 1, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.setRgbLed( + script.getStringField('POSITION', script), + script.getStringField('COLOR', script) + ); + return script.callReturn(); + } + ), + // 모든 RGB LED를 끄는 블록 + jikko_darae_rgb_clear: command( + [], + { + type: 'jikko_darae_rgb_clear', + }, + {}, + (sprite, script) => { + Entry.JIKKO_DARAE.sendProtocol(Entry.JIKKO_DARAE.pins.NEOPIXEL, [ + Entry.JIKKO_DARAE.protocol.device.NEOPIXEL, + Entry.JIKKO_DARAE.protocol.action.CLEAR, + ]); + Entry.JIKKO_DARAE.rgbUsed = false; + return script.callReturn(); + } + ), + + // 입력 블록 구분 제목 + jikko_darae_input_title: title('jikko_darae_input_title'), + // 풀다운 방식 스위치의 눌림 상태를 읽는 블록 + jikko_darae_switch_pull_down: valueBlock( + [dropdown(digitalPinOptions, '12')], + { + params: ['12'], + type: 'jikko_darae_switch_pull_down', + }, + { + PIN: 0, + }, + (sprite, script) => Number(readDigital(script, false)) + ), + // 풀업 방식 스위치의 눌림 상태를 읽는 블록 + jikko_darae_switch_pull_up: valueBlock( + [dropdown(digitalPinOptions, '12')], + { + params: ['12'], + type: 'jikko_darae_switch_pull_up', + }, + { + PIN: 0, + }, + (sprite, script) => Number(readDigital(script, true)) + ), + // 가변저항의 아날로그 값을 읽는 블록 + jikko_darae_variable_resistor: valueBlock( + [dropdown(variableResistorPinOptions, '1')], + { + params: ['1'], + type: 'jikko_darae_variable_resistor', + }, + { + PIN: 0, + }, + (sprite, script) => { + const pin = script.getNumberValue('PIN', script); + Entry.JIKKO_DARAE.subscribe(pin, Entry.JIKKO_DARAE.protocol.device.ANALOG); + return Entry.JIKKO_DARAE.getFirstPortValue( + [`analog_${pin}`, `A${pin}`, pin], + 0 + ); + } + ), + // 자이로 센서의 가속도 또는 각속도 X/Y/Z 값을 읽는 블록 + jikko_darae_gyro_acceleration: valueBlock( + [ + dropdown( + [ + ['가속도', 'acceleration'], + ['자이로', 'angularVelocity'], + ], + 'acceleration' + ), + dropdown(axisOptions, 'X'), + ], + { + params: ['acceleration', 'X'], + type: 'jikko_darae_gyro_acceleration', + }, + { + TYPE: 0, + AXIS: 1, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.subscribeExtended( + Entry.JIKKO_DARAE.protocol.device.GYRO + ); + const type = script.getStringField('TYPE', script); + const axis = script.getStringField('AXIS', script); + const sensorValue = Entry.JIKKO_DARAE.getFirstPortValue( + [`GYRO_${type}_${axis}`, `gyro_${type}_${axis.toLowerCase()}`], + 0 + ); + + // MPU6050의 ±2g 원시 가속도값(16384 LSB/g)을 m/s²로 변환한다. + // 각속도값은 펌웨어에서 받은 값을 기존과 동일하게 그대로 반환한다. + if (type === 'acceleration') { + const acceleration = (sensorValue / 16384) * 9.80665; + return Math.round(acceleration * 100) / 100; + } + + return sensorValue; + } + ), + // 자이로 센서로 계산한 X/Y 기울기 각도를 읽는 블록 + jikko_darae_gyro_angle: valueBlock( + [dropdown(twoAxisOptions, 'X')], + { + params: ['X'], + type: 'jikko_darae_gyro_angle', + }, + { + AXIS: 0, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.subscribeExtended( + Entry.JIKKO_DARAE.protocol.device.GYRO + ); + const axis = script.getStringField('AXIS', script); + return ( + Entry.JIKKO_DARAE.getFirstPortValue( + [`GYRO_ANGLE_${axis}`, `gyro_angle_${axis.toLowerCase()}`], + 0 + ) / 100 + ); + } + ), + // 자이로 센서 내부 온도를 읽는 블록 + jikko_darae_gyro_temperature: valueBlock( + [], + { + type: 'jikko_darae_gyro_temperature', + }, + {}, + () => { + Entry.JIKKO_DARAE.subscribeExtended( + Entry.JIKKO_DARAE.protocol.device.GYRO + ); + return ( + Entry.JIKKO_DARAE.getFirstPortValue( + ['GYRO_TEMPERATURE', 'gyro_temperature'], + 0 + ) / 100 + ); + } + ), + // 옵티컬 플로우 센서의 X/Y 이동값을 읽는 블록 + jikko_darae_optical: valueBlock( + [dropdown(twoAxisOptions, 'X')], + { + params: ['X'], + type: 'jikko_darae_optical', + }, + { + AXIS: 0, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.subscribeExtended( + Entry.JIKKO_DARAE.protocol.device.OPTICAL + ); + const axis = script.getStringField('AXIS', script); + return Entry.JIKKO_DARAE.getFirstPortValue( + [`OPTICAL_${axis}`, `optical_${axis.toLowerCase()}`], + 0 + ); + } + ), + // IR 거리 센서의 밀리미터 값을 읽는 블록 + jikko_darae_ir_distance: valueBlock( + [], + { + type: 'jikko_darae_ir_distance', + }, + {}, + () => { + Entry.JIKKO_DARAE.subscribeExtended( + Entry.JIKKO_DARAE.protocol.device.IR_DISTANCE + ); + return Entry.JIKKO_DARAE.getFirstPortValue( + ['IR_DISTANCE', 'ir_distance'], + 0 + ); + } + ), + + // 모터 블록 구분 제목 + jikko_darae_motor_title: title('jikko_darae_motor_title'), + // PWM 값으로 DC 모터 세기를 0~5 단계로 조절하는 블록 + jikko_darae_dc_motor_power: command( + [dropdown(motorPinOptions, '5'), { type: 'Block', accept: 'string', value: '5' }], + { + params: ['5', '5'], + type: 'jikko_darae_dc_motor_power', + }, + { + PIN: 0, + POWER: 1, + }, + (sprite, script) => { + const power = Math.max(0, Math.min(5, script.getNumberValue('POWER', script))); + Entry.JIKKO_DARAE.writePin( + script.getNumberValue('PIN', script), + Entry.JIKKO_DARAE.protocol.device.ANALOG, + Math.round(power * 51) + ); + return script.callReturn(); + } + ), + // DC 모터 출력을 켜거나 끄는 블록 + jikko_darae_dc_motor_switch: command( + [ + dropdown(motorPinOptions, '5'), + dropdown( + [ + ['켜기', '1'], + ['끄기', '0'], + ], + '1' + ), + ], + { + params: ['5', '1'], + type: 'jikko_darae_dc_motor_switch', + }, + { + PIN: 0, + VALUE: 1, + }, + (sprite, script) => { + Entry.JIKKO_DARAE.writePin( + script.getNumberValue('PIN', script), + Entry.JIKKO_DARAE.protocol.device.DIGITAL, + script.getNumberValue('VALUE', script) + ); + return script.callReturn(); + } + ), + + // Wi-Fi 조종값 블록 구분 제목 + jikko_darae_wifi_title: title('jikko_darae_wifi_title'), + // 휴대폰 조종기의 Roll 값을 읽는 블록 + jikko_darae_wifi_roll: valueBlock([], { type: 'jikko_darae_wifi_roll' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue(['WIFI_ROLL', 'wifi_roll', 'ROLL', 'roll'], 0) + ), + // 휴대폰 조종기의 Pitch 값을 읽는 블록 + jikko_darae_wifi_pitch: valueBlock([], { type: 'jikko_darae_wifi_pitch' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_PITCH', 'wifi_pitch', 'PITCH', 'pitch'], + 0 + ) + ), + // 휴대폰 조종기의 Yaw 값을 읽는 블록 + jikko_darae_wifi_yaw: valueBlock([], { type: 'jikko_darae_wifi_yaw' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue(['WIFI_YAW', 'wifi_yaw', 'YAW', 'yaw'], 0) + ), + // 휴대폰 조종기의 Throttle 값을 읽는 블록 + jikko_darae_wifi_throttle: valueBlock( + [], + { type: 'jikko_darae_wifi_throttle' }, + {}, + () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_THROTTLE', 'wifi_throttle', 'THROTTLE', 'throttle'], + 0 + ) + ), + // 휴대폰 앱의 ARM/DISARM 상태를 1 또는 0으로 읽는 블록 + jikko_darae_wifi_arming: valueBlock([], { type: 'jikko_darae_wifi_arming' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_ARMING', 'wifi_arming', 'ARMING', 'arming'], + 0 + ) + ), + // 휴대폰 앱에서 선택한 RGB LED 번호를 읽는 블록 + jikko_darae_wifi_rgb_pin: valueBlock([], { type: 'jikko_darae_wifi_rgb_pin' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_RGB_PIN', 'wifi_rgb_pin', 'RGB_PIN', 'rgb_pin'], + 0 + ) + ), + // 휴대폰 앱의 RGB 빨간색 값을 읽는 블록 + jikko_darae_wifi_rgb_r: valueBlock([], { type: 'jikko_darae_wifi_rgb_r' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_RGB_R', 'wifi_rgb_r', 'RGB_R', 'rgb_r'], + 0 + ) + ), + // 휴대폰 앱의 RGB 초록색 값을 읽는 블록 + jikko_darae_wifi_rgb_g: valueBlock([], { type: 'jikko_darae_wifi_rgb_g' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_RGB_G', 'wifi_rgb_g', 'RGB_G', 'rgb_g'], + 0 + ) + ), + // 휴대폰 앱의 RGB 파란색 값을 읽는 블록 + jikko_darae_wifi_rgb_b: valueBlock([], { type: 'jikko_darae_wifi_rgb_b' }, {}, () => + Entry.JIKKO_DARAE.getFirstPortValue( + ['WIFI_RGB_B', 'wifi_rgb_b', 'RGB_B', 'rgb_b'], + 0 + ) + ), + + }; + + const categoryColors = { + led: { color: '#DF3075', dark: '#B91F5B' }, + input: { color: '#6747F5', dark: '#4D31CC' }, + motor: { color: '#B544D4', dark: '#902EB0' }, + wifi: { color: '#4668EF', dark: '#2F4DC5' }, + }; + const categoryByType = { + jikko_darae_led: 'led', + jikko_darae_led_brightness: 'led', + jikko_darae_rgb_color: 'led', + jikko_darae_rgb_clear: 'led', + jikko_darae_switch_pull_down: 'input', + jikko_darae_switch_pull_up: 'input', + jikko_darae_variable_resistor: 'input', + jikko_darae_gyro_acceleration: 'input', + jikko_darae_gyro_angle: 'input', + jikko_darae_gyro_temperature: 'input', + jikko_darae_optical: 'input', + jikko_darae_ir_distance: 'input', + jikko_darae_dc_motor_power: 'motor', + jikko_darae_dc_motor_switch: 'motor', + jikko_darae_wifi_roll: 'wifi', + jikko_darae_wifi_pitch: 'wifi', + jikko_darae_wifi_yaw: 'wifi', + jikko_darae_wifi_throttle: 'wifi', + jikko_darae_wifi_arming: 'wifi', + jikko_darae_wifi_rgb_pin: 'wifi', + jikko_darae_wifi_rgb_r: 'wifi', + jikko_darae_wifi_rgb_g: 'wifi', + jikko_darae_wifi_rgb_b: 'wifi', + }; + + Object.keys(categoryByType).forEach((type) => { + const block = blocks[type]; + const colors = categoryColors[categoryByType[type]]; + if (!block) { + return; + } + + block.color = colors.color; + block.outerLine = colors.dark; + (block.params || []).forEach((param) => { + if (param && param.type === 'Dropdown') { + param.bgColor = colors.dark; + param.arrowColor = '#FFFFFF'; + } + }); + }); + + return blocks; + } +})(); + +module.exports = Entry.JIKKO_DARAE; diff --git a/src/playground/blocks/hardware/block_jikko_drone.js b/src/playground/blocks/hardware/block_jikko_drone.js new file mode 100644 index 0000000000..77399e5a12 --- /dev/null +++ b/src/playground/blocks/hardware/block_jikko_drone.js @@ -0,0 +1,597 @@ +'use strict'; + +/** + * 직코 드론 EntryJS 하드웨어 블록 모듈 + * + * 명령 전송 흐름: + * 엔트리 블록 -> sendProtocol() -> Entry.hw.sendQueue.SET + * -> Entry Hardware -> 드론 펌웨어 + * + * 데이터 수신 흐름: + * 드론 펌웨어 -> Entry Hardware -> Entry.hw.portData + * -> afterReceive() 및 값 읽기 블록 + */ +Entry.JIKKO_DRONE = new (class JIKKO_DRONE { + // 하드웨어 정보, 통신 규격과 드론 제어 상태를 준비한다. + constructor() { + this.id = '47.7'; + this.name = 'JIKKO_DRONE'; + this.url = 'https://www.makeitall.co.kr/'; + this.imageName = 'jikko.png'; + this.title = { ko: '직코 드론', en: 'JIKKO DRONE' }; + + // 통신 프로토콜 V2.0.1 정의 + this.protocol = { + version: '2.0.1', + HEADER_1: 0xff, + HEADER_2: 0xfd, + instruction: { + WRITE_MASK: 0x00, + READ_MASK: 0x40, + I2C_MASK: 0x80, + ETC: 0xff, + }, + device: { + DIGITAL: 0x01, + ANALOG: 0x02, + SUBSCRIBE: 0x03, + NEOPIXEL: 0x04, + DF_PLAYER: 0x05, + BUZZER: 0x06, + SERVO: 0x07, + DOT_MATRIX: 0x08, + DHT11: 0x09, + ULTRASONIC: 0x0a, + DUST: 0x0b, + LINE_TRACER: 0x0c, + WIFI: 0x0d, + DRONE: 0x20, + }, + drone: { + PING: 0x01, + TARGET_SLAVE: 0x02, + COMMAND_LINK_TEST: 0x01, + CONTROL: 0x10, + ARM: 0x11, + DISARM: 0x12, + EMERGENCY_STOP: 0x13, + STATUS: 0x14, + }, + droneCommand: { + CALIBRATE: 0x01, + ARM: 0x03, + DISARM: 0x05, + MOVE_TIME: 0x10, + MOVE_DISTANCE: 0x20, + TAKEOFF: 0x30, + LANDING: 0x35, + }, + droneDirection: { + FRONT: 0x10, + BACK: 0x12, + LEFT: 0x14, + RIGHT: 0x16, + COUNTER_CLOCKWISE: 0x18, + CLOCKWISE: 0x19, + UP: 0x1a, + DOWN: 0x1c, + }, + action: { + INIT: 0x01, + CLEAR: 0x02, + WRITE: 0x03, + READ: 0x04, + OUTPUT: 0x05, + INPUT: 0x06, + BRIGHTNESS: 0x07, + VOLUME: 0x08, + TEMPERATURE: 0x09, + HUMIDITY: 0x0a, + PRESSURE: 0x0b, + ALL: 0xff, + }, + response: { + OK: 0xa0, + CRC_ERROR: 0xf0, + UNSUPPORTED_COMMAND: 0xf1, + INVALID_PARAMETER: 0xf2, + }, + }; + + // 보드별 고정 배선 정보를 통신 프로토콜과 분리해 관리한다. + this.pins = { + LED: 2, + BUZZER: 27, + NEOPIXEL: 23, + DOT_MATRIX: { DIN: 23, CS: 5, CLK: 18 }, + }; + this.droneHwSequence = 0; + this.blockMenuBlocks = [ + 'jikko_drone_flight_title', + 'jikko_drone_calibrate', + 'jikko_drone_flight_arm', + 'jikko_drone_flight_disarm', + 'jikko_drone_takeoff', + 'jikko_drone_landing', + 'jikko_drone_move_front_time', + 'jikko_drone_move_back_time', + 'jikko_drone_move_left_time', + 'jikko_drone_move_right_time', + 'jikko_drone_move_ccw_time', + 'jikko_drone_move_cw_time', + 'jikko_drone_move_up_time', + 'jikko_drone_move_down_time', + 'jikko_drone_move_front_distance', + 'jikko_drone_move_back_distance', + 'jikko_drone_move_left_distance', + 'jikko_drone_move_right_distance', + 'jikko_drone_rotate_ccw_angle', + 'jikko_drone_rotate_cw_angle', + 'jikko_drone_move_up_distance', + 'jikko_drone_move_down_distance', + ]; + } + + // FF FD 패킷을 만들고 CRC-16/MODBUS를 계산하는 공통 처리 + clampByte(value) { + return Math.max(0, Math.min(255, Math.round(Number(value) || 0))); + } + + /** + * CRC-16/MODBUS 체크섬을 계산한다. + * 명령 바이트부터 마지막 매개변수까지를 계산 범위로 사용한다. + */ + calculateCrc(bytes) { + let crc = 0xffff; + bytes.forEach((value) => { + crc ^= this.clampByte(value); + for (let bit = 0; bit < 8; bit++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xa001 : crc >>> 1; + } + }); + return crc & 0xffff; + } + + /** + * 패킷 구성: FF FD | 매개변수 길이 | 명령 | 매개변수... | CRC 하위 | CRC 상위 + */ + buildPacket(instruction, parameters = []) { + const normalizedInstruction = this.clampByte(instruction); + const normalizedParameters = parameters.map((value) => this.clampByte(value)); + const crc = this.calculateCrc([normalizedInstruction].concat(normalizedParameters)); + + return [ + this.protocol.HEADER_1, + this.protocol.HEADER_2, + normalizedParameters.length, + normalizedInstruction, + ].concat(normalizedParameters, [crc & 0xff, (crc >>> 8) & 0xff]); + } + + /** + * 애니멀 키링과 동일한 EntryJS -> Entry Hardware SET 메시지 형식을 사용한다. + */ + // EntryJS 블록 명령을 Entry Hardware 전송 큐에 기록한다. + sendProtocol(instruction, parameters = [], options = {}) { + const normalizedInstruction = this.clampByte(instruction); + const normalizedParameters = parameters.map((value) => this.clampByte(value)); + const packet = this.buildPacket(normalizedInstruction, normalizedParameters); + + Entry.hw.sendQueue = Entry.hw.sendQueue || {}; + Entry.hw.sendQueue.SET = { + protocol: this.protocol.version, + instruction: normalizedInstruction, + parameters: normalizedParameters, + packet, + time: options.time || Date.now(), + }; + Entry.hw.update(); + return packet; + } + + sendEntryHwDroneCommand(command, value1 = 0, value2 = 0, value3 = 0) { + this.droneHwSequence += 1; + const data = { + sequence: this.droneHwSequence, + command: this.clampByte(command), + value1: this.clampByte(value1), + value2: this.clampByte(value2), + value3: this.clampByte(value3), + }; + + Entry.hw.sendQueue = Entry.hw.sendQueue || {}; + Entry.hw.sendQueue.DRONE_COMMAND = data; + Entry.hw.update(); + return data; + } + + sendDroneMoveTime(direction, speed, seconds) { + const safeSpeed = Math.max(0, Math.min(100, Math.round(Number(speed) || 0))); + const duration = Math.max(1, Math.min(255, Math.round((Number(seconds) || 0) * 10))); + return this.sendEntryHwDroneCommand( + this.protocol.droneCommand.MOVE_TIME, + direction, + safeSpeed, + duration + ); + } + + sendDroneMoveDistance(direction, value) { + return this.sendEntryHwDroneCommand( + this.protocol.droneCommand.MOVE_DISTANCE, + direction, + this.clampByte(value), + 0 + ); + } + + writePin(pin, device, value) { + return this.sendProtocol(pin, [device, value]); + } + + // 작품 정지 시 출력 장치와 내부 상태를 안전하게 초기화한다. + setZero() { + this.writePin(this.pins.LED, this.protocol.device.DIGITAL, 0); + this.sendProtocol(this.pins.NEOPIXEL, [ + this.protocol.device.NEOPIXEL, + this.protocol.action.CLEAR, + ]); + } + + // 블록 화면에 표시할 한국어와 영어 문구를 정의한다. + setLanguage() { + return { + ko: { + template: { + jikko_drone_comm_title: '통신 테스트', + jikko_drone_master_slave_ping: '마스터-슬레이브 통신 확인', + jikko_drone_led_title: 'LED', + jikko_drone_led: 'LED %1 핀 %2', + jikko_drone_led_brightness: 'LED ( %1 핀) 밝기 %2 출력 (0~255)', + jikko_drone_rgb_start: 'RGB LED 시작하기', + jikko_drone_rgb_color: 'RGB LED %1 %2 색 출력', + jikko_drone_rgb_clear: 'RGB LED 모든 LED 끄기', + jikko_drone_input_title: '입력', + jikko_drone_switch_pull_down: '스위치 %1 핀 눌림 상태(풀다운)', + jikko_drone_switch_pull_up: '스위치 %1 핀 눌림 상태(풀업)', + jikko_drone_variable_resistor: '가변저항 %1 핀 값', + jikko_drone_gyro_acceleration: '자이로센서 %1 %2 축 값', + jikko_drone_gyro_angle: '자이로센서 각도 %1 축 값', + jikko_drone_gyro_temperature: '자이로센서 온도 값', + jikko_drone_optical: '옵티컬 센서 %1 축 감지값', + jikko_drone_ir_distance: 'IR 센서 거리(mm) 값', + jikko_drone_motor_title: '모터', + jikko_drone_dc_motor_power: 'DC 모터 ( %1 핀) 세기 %2 출력 (0~5)', + jikko_drone_dc_motor_switch: 'DC 모터 %1 핀 %2', + jikko_drone_wifi_title: '와이파이', + jikko_drone_wifi_start: '와이파이 모듈 시작하기', + jikko_drone_wifi_check: 'Master-Slave 와이파이 연결 확인', + jikko_drone_wifi_connected: '와이파이 연결됨?', + jikko_drone_wifi_rtt: '와이파이 응답 시간(ms)', + jikko_drone_target: '제어할 드론 번호를 %1 로 정하기', + jikko_drone_arm: '드론 시동 걸기', + jikko_drone_disarm: '드론 시동 끄기', + jikko_drone_emergency_stop: '드론 비상 정지', + jikko_drone_control: '드론 롤 %1 피치 %2 요 %3 스로틀 %4 로 제어하기', + jikko_drone_wifi_roll: 'Roll(롤) 값 읽기', + jikko_drone_wifi_pitch: 'Pitch(피치) 값 읽기', + jikko_drone_wifi_yaw: 'Yaw(요) 값 읽기', + jikko_drone_wifi_throttle: 'Throttle(쓰로틀) 값 읽기', + jikko_drone_wifi_arming: '시동 값 읽기', + jikko_drone_wifi_rgb_pin: '조종기 RGB 핀 값 읽기', + jikko_drone_wifi_rgb_r: '조종기 R 값 읽기', + jikko_drone_wifi_rgb_g: '조종기 G 값 읽기', + jikko_drone_wifi_rgb_b: '조종기 B 값 읽기', + jikko_drone_keyboard_title: '키보드', + jikko_drone_keyboard_start: '키보드 제어 시작하기', + jikko_drone_keyboard_stop: '키보드 제어 종료하기', + jikko_drone_keyboard_char: '키보드 문자 (하나) %1 입력하기', + jikko_drone_keyboard_text: '키보드 문자 (여러 개) %1 입력하기', + jikko_drone_keyboard_special: '키보드 특수키 %1 입력하기', + jikko_drone_keyboard_pressed: '%1 키보드 눌림', + jikko_drone_keyboard_hold: '키보드 버튼 (문자, 특수키) %1 눌림 상태 유지하기', + jikko_drone_keyboard_release: '키보드 눌림 상태 해제하기', + jikko_drone_keyboard_ctrl: '%1', + jikko_drone_keyboard_function: '%1', + jikko_drone_keyboard_arrow: '%1', + jikko_drone_mouse_title: '마우스', + jikko_drone_mouse_start: '마우스 제어 시작하기', + jikko_drone_mouse_stop: '마우스 제어 종료하기', + jikko_drone_mouse_click: '마우스 %1 클릭하기', + jikko_drone_mouse_move: '마우스 커서 X축 %1 Y축 %2 만큼 이동하기', + jikko_drone_mouse_wheel: '마우스 Wheel %1 만큼 이동하기', + jikko_drone_mouse_hold: '마우스 %1 눌림 상태 유지하기', + jikko_drone_mouse_release: '마우스 눌림 상태 해제하기', + jikko_drone_flight_title: '드론', + jikko_drone_calibrate: '센서보정(캘리브레이션)', + jikko_drone_flight_arm: '시동 켬', + jikko_drone_flight_disarm: '시동 끔', + jikko_drone_takeoff: '이륙', + jikko_drone_landing: '착륙', + jikko_drone_move_front_time: '앞으로 속도 %1 으로 %2 (초)동안 이동', + jikko_drone_move_back_time: '뒤로 속도 %1 으로 %2 (초)동안 이동', + jikko_drone_move_left_time: '왼쪽으로 속도 %1 으로 %2 (초)동안 이동', + jikko_drone_move_right_time: '오른쪽으로 속도 %1 으로 %2 (초)동안 이동', + jikko_drone_move_ccw_time: '반시계 방향으로 속도 %1 으로 %2 (초)동안 이동', + jikko_drone_move_cw_time: '시계 방향으로 속도 %1 으로 %2 (초)동안 이동', + jikko_drone_move_up_time: '속도 %1 으로 %2 (초)동안 상승', + jikko_drone_move_down_time: '속도 %1 으로 %2 (초)동안 하강', + jikko_drone_move_front_distance: '앞으로 %1 cm 이동', + jikko_drone_move_back_distance: '뒤로 %1 cm 이동', + jikko_drone_move_left_distance: '왼쪽으로 %1 cm 이동', + jikko_drone_move_right_distance: '오른쪽으로 %1 cm 이동', + jikko_drone_rotate_ccw_angle: '반시계 방향으로 %1 ° 회전(0~255)', + jikko_drone_rotate_cw_angle: '시계 방향으로 %1 ° 회전(0~255)', + jikko_drone_move_up_distance: '%1 cm 상승', + jikko_drone_move_down_distance: '%1 cm 하강', + }, + }, + en: { + template: { + jikko_drone_comm_title: 'Communication test', + jikko_drone_master_slave_ping: 'Test master-slave communication', + jikko_drone_led_title: 'LED', + jikko_drone_led: 'LED pin %1 %2', + jikko_drone_led_brightness: 'LED pin %1 brightness %2 (0-255)', + jikko_drone_rgb_start: 'Initialize RGB LEDs', + jikko_drone_rgb_color: 'Set %1 RGB LED to %2', + jikko_drone_rgb_clear: 'Turn off all RGB LEDs', + jikko_drone_input_title: 'Input', + jikko_drone_switch_pull_down: 'Switch pin %1 pressed (pull-down)', + jikko_drone_switch_pull_up: 'Switch pin %1 pressed (pull-up)', + jikko_drone_variable_resistor: 'Variable resistor %1 value', + jikko_drone_gyro_acceleration: 'Gyroscope %1 %2-axis value', + jikko_drone_gyro_angle: 'Gyroscope angle %1-axis value', + jikko_drone_gyro_temperature: 'Gyroscope temperature', + jikko_drone_optical: 'Optical sensor %1-axis value', + jikko_drone_ir_distance: 'IR distance (mm)', + jikko_drone_motor_title: 'Motor', + jikko_drone_dc_motor_power: 'DC motor pin %1 power %2 (0-5)', + jikko_drone_dc_motor_switch: 'DC motor pin %1 %2', + jikko_drone_wifi_title: 'Wi-Fi', + jikko_drone_wifi_start: 'Initialize Wi-Fi module', + jikko_drone_wifi_check: 'Check Master-Slave Wi-Fi link', + jikko_drone_wifi_connected: 'Wi-Fi connected?', + jikko_drone_wifi_rtt: 'Wi-Fi response time (ms)', + jikko_drone_target: 'Set target drone to %1', + jikko_drone_arm: 'Arm drone', + jikko_drone_disarm: 'Disarm drone', + jikko_drone_emergency_stop: 'Emergency stop drone', + jikko_drone_control: 'Control drone roll %1 pitch %2 yaw %3 throttle %4', + jikko_drone_wifi_roll: 'Read Roll', + jikko_drone_wifi_pitch: 'Read Pitch', + jikko_drone_wifi_yaw: 'Read Yaw', + jikko_drone_wifi_throttle: 'Read Throttle', + jikko_drone_wifi_arming: 'Read arming value', + jikko_drone_wifi_rgb_pin: 'Read controller RGB pin', + jikko_drone_wifi_rgb_r: 'Read controller R', + jikko_drone_wifi_rgb_g: 'Read controller G', + jikko_drone_wifi_rgb_b: 'Read controller B', + jikko_drone_keyboard_title: 'Keyboard', + jikko_drone_keyboard_start: 'Start keyboard control', + jikko_drone_keyboard_stop: 'Stop keyboard control', + jikko_drone_keyboard_char: 'Type one keyboard character %1', + jikko_drone_keyboard_text: 'Type keyboard text %1', + jikko_drone_keyboard_special: 'Press special keyboard key %1', + jikko_drone_keyboard_pressed: 'Keyboard key %1 pressed', + jikko_drone_keyboard_hold: 'Hold keyboard key %1', + jikko_drone_keyboard_release: 'Release all keyboard keys', + jikko_drone_keyboard_ctrl: '%1', + jikko_drone_keyboard_function: '%1', + jikko_drone_keyboard_arrow: '%1', + jikko_drone_mouse_title: 'Mouse', + jikko_drone_mouse_start: 'Start mouse control', + jikko_drone_mouse_stop: 'Stop mouse control', + jikko_drone_mouse_click: 'Click %1 mouse button', + jikko_drone_mouse_move: 'Move mouse X %1 Y %2', + jikko_drone_mouse_wheel: 'Move mouse wheel by %1', + jikko_drone_mouse_hold: 'Hold %1 mouse button', + jikko_drone_mouse_release: 'Release all mouse buttons', + jikko_drone_flight_title: 'Drone', + jikko_drone_calibrate: 'Calibrate sensors', + jikko_drone_flight_arm: 'Arm', + jikko_drone_flight_disarm: 'Disarm', + jikko_drone_takeoff: 'Take off', + jikko_drone_landing: 'Land', + jikko_drone_move_front_time: 'Move forward at speed %1 for %2 seconds', + jikko_drone_move_back_time: 'Move backward at speed %1 for %2 seconds', + jikko_drone_move_left_time: 'Move left at speed %1 for %2 seconds', + jikko_drone_move_right_time: 'Move right at speed %1 for %2 seconds', + jikko_drone_move_ccw_time: + 'Rotate counter-clockwise at speed %1 for %2 seconds', + jikko_drone_move_cw_time: 'Rotate clockwise at speed %1 for %2 seconds', + jikko_drone_move_up_time: 'Ascend at speed %1 for %2 seconds', + jikko_drone_move_down_time: 'Descend at speed %1 for %2 seconds', + jikko_drone_move_front_distance: 'Move forward %1 cm', + jikko_drone_move_back_distance: 'Move backward %1 cm', + jikko_drone_move_left_distance: 'Move left %1 cm', + jikko_drone_move_right_distance: 'Move right %1 cm', + jikko_drone_rotate_ccw_angle: 'Rotate counter-clockwise %1 degrees (0-255)', + jikko_drone_rotate_cw_angle: 'Rotate clockwise %1 degrees (0-255)', + jikko_drone_move_up_distance: 'Ascend %1 cm', + jikko_drone_move_down_distance: 'Descend %1 cm', + }, + }, + }; + } + + // 각 드론 블록의 모양, 입력값과 실행 명령을 정의한다. + getBlocks() { + const title = (type) => ({ + skeleton: 'basic_text', + color: EntryStatic.colorSet.common.TRANSPARENT, + fontColor: '#333333', + template: Lang.template[type], + def: { type }, + class: 'jikko_drone', + isNotFor: ['JIKKO_DRONE'], + fontSize: 22, + }); + const droneTheme = { color: '#FF5656', dark: '#F03D37' }; + const hidBase = (theme, skeleton, params, def, paramsKeyMap, func) => ({ + color: theme.color, + outerLine: theme.dark, + fontColor: '#FFFFFF', + skeleton, + statements: [], + params, + def, + paramsKeyMap, + class: 'jikko_drone', + isNotFor: ['JIKKO_DRONE'], + func, + }); + const hidCommand = (theme, params, def, paramsKeyMap, func) => + hidBase(theme, 'basic', params, def, paramsKeyMap, func); + const textInput = (value = '') => ({ + type: 'Block', + accept: 'string', + value, + }); + const droneSimpleBlock = (type, droneCommand) => + hidCommand(droneTheme, [], { type }, {}, (sprite, script) => { + Entry.JIKKO_DRONE.sendEntryHwDroneCommand(droneCommand); + return script.callReturn(); + }); + const droneTimeBlock = (type, direction) => + hidCommand( + droneTheme, + [textInput('50'), textInput('1')], + { params: ['50', '1'], type }, + { SPEED: 0, SECONDS: 1 }, + (sprite, script) => { + Entry.JIKKO_DRONE.sendDroneMoveTime( + direction, + script.getNumberValue('SPEED', script), + script.getNumberValue('SECONDS', script) + ); + return script.callReturn(); + } + ); + const droneDistanceBlock = (type, direction, defaultValue = '10') => + hidCommand( + droneTheme, + [textInput(defaultValue)], + { params: [defaultValue], type }, + { VALUE: 0 }, + (sprite, script) => { + Entry.JIKKO_DRONE.sendDroneMoveDistance( + direction, + script.getNumberValue('VALUE', script) + ); + return script.callReturn(); + } + ); + return { + // 드론 비행 블록 구분 제목 + jikko_drone_flight_title: title('jikko_drone_flight_title'), + // 비행 전 센서의 기준값을 보정하는 블록 + jikko_drone_calibrate: droneSimpleBlock( + 'jikko_drone_calibrate', + Entry.JIKKO_DRONE.protocol.droneCommand.CALIBRATE + ), + // 드론 모터 시동을 켜는 블록 + jikko_drone_flight_arm: droneSimpleBlock( + 'jikko_drone_flight_arm', + Entry.JIKKO_DRONE.protocol.droneCommand.ARM + ), + // 드론 모터 시동을 끄는 블록 + jikko_drone_flight_disarm: droneSimpleBlock( + 'jikko_drone_flight_disarm', + Entry.JIKKO_DRONE.protocol.droneCommand.DISARM + ), + // 드론을 자동으로 이륙시키는 블록 + jikko_drone_takeoff: droneSimpleBlock( + 'jikko_drone_takeoff', + Entry.JIKKO_DRONE.protocol.droneCommand.TAKEOFF + ), + // 드론을 자동으로 착륙시키는 블록 + jikko_drone_landing: droneSimpleBlock( + 'jikko_drone_landing', + Entry.JIKKO_DRONE.protocol.droneCommand.LANDING + ), + // 지정한 속도와 시간만큼 앞으로 이동하는 블록 + jikko_drone_move_front_time: droneTimeBlock( + 'jikko_drone_move_front_time', + Entry.JIKKO_DRONE.protocol.droneDirection.FRONT + ), + // 지정한 속도와 시간만큼 뒤로 이동하는 블록 + jikko_drone_move_back_time: droneTimeBlock( + 'jikko_drone_move_back_time', + Entry.JIKKO_DRONE.protocol.droneDirection.BACK + ), + // 지정한 속도와 시간만큼 왼쪽으로 이동하는 블록 + jikko_drone_move_left_time: droneTimeBlock( + 'jikko_drone_move_left_time', + Entry.JIKKO_DRONE.protocol.droneDirection.LEFT + ), + // 지정한 속도와 시간만큼 오른쪽으로 이동하는 블록 + jikko_drone_move_right_time: droneTimeBlock( + 'jikko_drone_move_right_time', + Entry.JIKKO_DRONE.protocol.droneDirection.RIGHT + ), + // 지정한 속도와 시간만큼 반시계 방향으로 회전하는 블록 + jikko_drone_move_ccw_time: droneTimeBlock( + 'jikko_drone_move_ccw_time', + Entry.JIKKO_DRONE.protocol.droneDirection.COUNTER_CLOCKWISE + ), + // 지정한 속도와 시간만큼 시계 방향으로 회전하는 블록 + jikko_drone_move_cw_time: droneTimeBlock( + 'jikko_drone_move_cw_time', + Entry.JIKKO_DRONE.protocol.droneDirection.CLOCKWISE + ), + // 지정한 속도와 시간만큼 상승하는 블록 + jikko_drone_move_up_time: droneTimeBlock( + 'jikko_drone_move_up_time', + Entry.JIKKO_DRONE.protocol.droneDirection.UP + ), + // 지정한 속도와 시간만큼 하강하는 블록 + jikko_drone_move_down_time: droneTimeBlock( + 'jikko_drone_move_down_time', + Entry.JIKKO_DRONE.protocol.droneDirection.DOWN + ), + // 지정한 거리만큼 앞으로 이동하는 블록 + jikko_drone_move_front_distance: droneDistanceBlock( + 'jikko_drone_move_front_distance', + Entry.JIKKO_DRONE.protocol.droneDirection.FRONT + ), + // 지정한 거리만큼 뒤로 이동하는 블록 + jikko_drone_move_back_distance: droneDistanceBlock( + 'jikko_drone_move_back_distance', + Entry.JIKKO_DRONE.protocol.droneDirection.BACK + ), + // 지정한 거리만큼 왼쪽으로 이동하는 블록 + jikko_drone_move_left_distance: droneDistanceBlock( + 'jikko_drone_move_left_distance', + Entry.JIKKO_DRONE.protocol.droneDirection.LEFT + ), + // 지정한 거리만큼 오른쪽으로 이동하는 블록 + jikko_drone_move_right_distance: droneDistanceBlock( + 'jikko_drone_move_right_distance', + Entry.JIKKO_DRONE.protocol.droneDirection.RIGHT + ), + // 지정한 각도만큼 반시계 방향으로 회전하는 블록 + jikko_drone_rotate_ccw_angle: droneDistanceBlock( + 'jikko_drone_rotate_ccw_angle', + Entry.JIKKO_DRONE.protocol.droneDirection.COUNTER_CLOCKWISE + ), + // 지정한 각도만큼 시계 방향으로 회전하는 블록 + jikko_drone_rotate_cw_angle: droneDistanceBlock( + 'jikko_drone_rotate_cw_angle', + Entry.JIKKO_DRONE.protocol.droneDirection.CLOCKWISE + ), + // 지정한 거리만큼 상승하는 블록 + jikko_drone_move_up_distance: droneDistanceBlock( + 'jikko_drone_move_up_distance', + Entry.JIKKO_DRONE.protocol.droneDirection.UP + ), + // 지정한 거리만큼 하강하는 블록 + jikko_drone_move_down_distance: droneDistanceBlock( + 'jikko_drone_move_down_distance', + Entry.JIKKO_DRONE.protocol.droneDirection.DOWN + ), + }; + } +})(); + +module.exports = Entry.JIKKO_DRONE;