|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +const fs = require('node:fs'); |
| 4 | +const path = require('node:path'); |
| 5 | +const { |
| 6 | + ROOT, |
| 7 | + compareStrings, |
| 8 | + getPublishablePackages, |
| 9 | + isCoreDirectory, |
| 10 | + readJson, |
| 11 | + writeJson, |
| 12 | +} = require('./genjson'); |
| 13 | + |
| 14 | +const CATALOG_FILE = path.join('catalog', 'python-libraries.json'); |
| 15 | +const TAG_CATEGORIES = { |
| 16 | + audio: 'audio', |
| 17 | + camera: 'vision-ai', |
| 18 | + clock: 'system', |
| 19 | + communication: 'network', |
| 20 | + core: 'core', |
| 21 | + data: 'data-science', |
| 22 | + 'data-processing': 'data-science', |
| 23 | + display: 'display', |
| 24 | + io: 'hardware-io', |
| 25 | + sensor: 'sensors', |
| 26 | + storage: 'system', |
| 27 | + system: 'system', |
| 28 | +}; |
| 29 | + |
| 30 | +// 型号优先,避免包含温度等辅助属性的复合传感器被归成单一类型。 |
| 31 | +const EXACT_HARDWARE_TYPES = { |
| 32 | + ads1x15: ['adc'], |
| 33 | + ahtx0: ['temperature', 'humidity'], |
| 34 | + apds9960: ['gesture', 'color', 'proximity'], |
| 35 | + bme280: ['temperature', 'humidity', 'pressure'], |
| 36 | + bme680: ['temperature', 'humidity', 'pressure', 'gas'], |
| 37 | + bmp280: ['temperature', 'pressure'], |
| 38 | + bno055: ['imu', 'accelerometer', 'gyroscope', 'magnetometer'], |
| 39 | + ccs811: ['gas'], |
| 40 | + dht: ['temperature', 'humidity'], |
| 41 | + ds18b20: ['temperature'], |
| 42 | + ds3231: ['rtc'], |
| 43 | + ina219: ['power'], |
| 44 | + lis3dh: ['accelerometer'], |
| 45 | + mcp3xxx: ['adc'], |
| 46 | + mlx90614: ['temperature'], |
| 47 | + mpu6050: ['imu', 'accelerometer', 'gyroscope'], |
| 48 | + neopixel: ['led'], |
| 49 | + pca9685: ['servo'], |
| 50 | + pn532: ['rfid'], |
| 51 | + scd4x: ['temperature', 'humidity', 'gas'], |
| 52 | + sgp30: ['gas'], |
| 53 | + tsl2591: ['light'], |
| 54 | + vl53l0x: ['distance'], |
| 55 | + vl53l1x: ['distance'], |
| 56 | + w1thermsensor: ['temperature'], |
| 57 | +}; |
| 58 | + |
| 59 | +const HARDWARE_TYPE_KEYWORDS = { |
| 60 | + imu: ['imu', '惯性', '6-axis', '9-axis', '六轴', '九轴'], |
| 61 | + accelerometer: ['accelerometer', 'accel', '加速度'], |
| 62 | + gyroscope: ['gyroscope', 'gyro', '陀螺仪'], |
| 63 | + magnetometer: ['magnetometer', 'compass', '磁力计', '指南针'], |
| 64 | + temperature: ['temperature sensor', 'thermometer', '温度传感器'], |
| 65 | + humidity: ['humidity sensor', '湿度传感器'], |
| 66 | + pressure: ['pressure sensor', 'barometer', '气压传感器', '压力传感器'], |
| 67 | + gas: ['gas sensor', 'air quality', 'co2 sensor', 'voc sensor', '气体传感器', '空气质量'], |
| 68 | + distance: ['distance sensor', 'ranging', '距离传感器', '测距', 'tof'], |
| 69 | + ultrasonic: ['ultrasonic', '超声波'], |
| 70 | + proximity: ['proximity sensor', '接近传感器'], |
| 71 | + light: ['light sensor', 'lux', 'ambient light', '光线传感器'], |
| 72 | + color: ['color sensor', 'rgb sensor', '颜色传感器'], |
| 73 | + gesture: ['gesture sensor', '手势传感器'], |
| 74 | + touch: ['touch sensor', 'capacitive touch', '触摸传感器'], |
| 75 | + oled: ['oled'], |
| 76 | + lcd: ['lcd', 'character display', '液晶'], |
| 77 | + tft: ['tft display', '彩屏'], |
| 78 | + led: ['led strip', 'neopixel', '灯带', '灯珠'], |
| 79 | + 'led-matrix': ['led matrix', '点阵'], |
| 80 | + servo: ['servo', '舵机'], |
| 81 | + stepper: ['stepper', '步进'], |
| 82 | + 'dc-motor': ['dc motor', 'motor driver', 'motorkit', '直流电机'], |
| 83 | + relay: ['relay', '继电器'], |
| 84 | + buzzer: ['buzzer', '蜂鸣器'], |
| 85 | + rfid: ['rfid reader', 'nfc reader'], |
| 86 | + gps: ['gps module', 'gnss module', '定位模块'], |
| 87 | + rtc: ['rtc module', 'real-time clock', 'real time clock', '实时时钟'], |
| 88 | + camera: ['camera', '摄像头'], |
| 89 | + microphone: ['microphone', '麦克风'], |
| 90 | + speaker: ['speaker', '扬声器'], |
| 91 | + 'sd-card': ['sd card', 'microsd', 'tf card'], |
| 92 | + power: ['power monitor', '电源监测'], |
| 93 | + adc: ['analog-to-digital', 'analog to digital', 'adc'], |
| 94 | +}; |
| 95 | + |
| 96 | +const COMMUNICATION_KEYWORDS = { |
| 97 | + i2c: ['i2c', 'iic', 'twi'], |
| 98 | + spi: ['spi'], |
| 99 | + uart: ['uart', 'serial port', 'serial-port', 'pyserial', 'lib-serial', 'minimalmodbus', 'python-periphery', '串口'], |
| 100 | + onewire: ['onewire', 'one-wire', 'one wire', '1-wire', '单总线'], |
| 101 | + gpio: ['gpio', 'digital pin'], |
| 102 | + pwm: ['pwm'], |
| 103 | + analog: ['analog', 'adc', 'iio'], |
| 104 | + wifi: ['wifi', 'http', 'httpx', 'aiohttp', 'mqtt', 'websocket', 'websockets', 'tcp', 'udp', 'coap', 'aiocoap'], |
| 105 | + ble: ['ble', 'bluetooth'], |
| 106 | + can: ['canbus', 'can bus', 'socketcan', 'python-can', 'cantools'], |
| 107 | + usb: ['usb', 'hidapi', 'pyftdi', 'pyusb'], |
| 108 | +}; |
| 109 | + |
| 110 | +function uniqueStrings(values, fieldName) { |
| 111 | + if (!Array.isArray(values)) { |
| 112 | + throw new Error(`${fieldName} 必须是数组`); |
| 113 | + } |
| 114 | + if (values.some((value) => typeof value !== 'string' || value === '')) { |
| 115 | + throw new Error(`${fieldName} 必须只包含非空字符串`); |
| 116 | + } |
| 117 | + return [...new Set(values)]; |
| 118 | +} |
| 119 | + |
| 120 | +function loadCatalog(rootDir = ROOT) { |
| 121 | + const catalogPath = path.join(rootDir, CATALOG_FILE); |
| 122 | + const catalog = readJson(catalogPath); |
| 123 | + if (!Array.isArray(catalog.libraries)) { |
| 124 | + throw new Error(`${catalogPath} 缺少 libraries 数组`); |
| 125 | + } |
| 126 | + |
| 127 | + const entries = new Map(); |
| 128 | + for (const entry of catalog.libraries) { |
| 129 | + if (!entry || typeof entry.id !== 'string' || entry.id === '') { |
| 130 | + throw new Error(`${catalogPath} 包含无效的 library.id`); |
| 131 | + } |
| 132 | + if (entries.has(entry.id)) { |
| 133 | + throw new Error(`${catalogPath} 包含重复 library.id: ${entry.id}`); |
| 134 | + } |
| 135 | + entries.set(entry.id, entry); |
| 136 | + } |
| 137 | + return entries; |
| 138 | +} |
| 139 | + |
| 140 | +function fallbackCategory(folderName, packageJson) { |
| 141 | + if (isCoreDirectory(folderName)) { |
| 142 | + return 'core'; |
| 143 | + } |
| 144 | + for (const tag of packageJson.tags || []) { |
| 145 | + if (TAG_CATEGORIES[tag]) { |
| 146 | + return TAG_CATEGORIES[tag]; |
| 147 | + } |
| 148 | + } |
| 149 | + return 'utility'; |
| 150 | +} |
| 151 | + |
| 152 | +function matchKeyword(text, keyword) { |
| 153 | + const lowerText = text.toLowerCase(); |
| 154 | + const lowerKeyword = keyword.toLowerCase(); |
| 155 | + if (lowerKeyword.length > 3) { |
| 156 | + return lowerText.includes(lowerKeyword); |
| 157 | + } |
| 158 | + |
| 159 | + const escapedKeyword = lowerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 160 | + return new RegExp(`(?:^|[^a-z0-9])${escapedKeyword}(?:[^a-z0-9]|$)`, 'i').test(lowerText); |
| 161 | +} |
| 162 | + |
| 163 | +function matchCommunicationKeyword(text, keyword) { |
| 164 | + const escapedKeyword = keyword.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 165 | + return new RegExp(`(?:^|[^a-z0-9])${escapedKeyword}(?:[^a-z0-9]|$)`, 'i').test(text); |
| 166 | +} |
| 167 | + |
| 168 | +function detectHardwareType(text, folderName) { |
| 169 | + const lowerText = text.toLowerCase(); |
| 170 | + const lowerName = folderName.toLowerCase(); |
| 171 | + const hardwareTypes = new Set(); |
| 172 | + |
| 173 | + for (const [model, types] of Object.entries(EXACT_HARDWARE_TYPES)) { |
| 174 | + if (lowerName.includes(model) || lowerText.includes(model)) { |
| 175 | + types.forEach((type) => hardwareTypes.add(type)); |
| 176 | + } |
| 177 | + } |
| 178 | + if (hardwareTypes.size > 0) { |
| 179 | + return [...hardwareTypes]; |
| 180 | + } |
| 181 | + |
| 182 | + for (const [type, keywords] of Object.entries(HARDWARE_TYPE_KEYWORDS)) { |
| 183 | + if (keywords.some((keyword) => matchKeyword(lowerText, keyword))) { |
| 184 | + hardwareTypes.add(type); |
| 185 | + } |
| 186 | + } |
| 187 | + return [...hardwareTypes]; |
| 188 | +} |
| 189 | + |
| 190 | +function detectCommunication(text) { |
| 191 | + const communications = []; |
| 192 | + for (const [communication, keywords] of Object.entries(COMMUNICATION_KEYWORDS)) { |
| 193 | + if (keywords.some((keyword) => matchCommunicationKeyword(text, keyword))) { |
| 194 | + communications.push(communication); |
| 195 | + } |
| 196 | + } |
| 197 | + return communications; |
| 198 | +} |
| 199 | + |
| 200 | +function buildInfo(folderName, packageJson, catalogEntry) { |
| 201 | + const supportedCores = uniqueStrings( |
| 202 | + packageJson.compatibility && packageJson.compatibility.type, |
| 203 | + `${folderName}.compatibility.type`, |
| 204 | + ); |
| 205 | + if (supportedCores.length === 0) { |
| 206 | + throw new Error(`${folderName}.compatibility.type 不能为空`); |
| 207 | + } |
| 208 | + |
| 209 | + const voltage = packageJson.compatibility.voltage === undefined |
| 210 | + ? [] |
| 211 | + : packageJson.compatibility.voltage; |
| 212 | + if (!Array.isArray(voltage)) { |
| 213 | + throw new Error(`${folderName}.compatibility.voltage 必须是数组`); |
| 214 | + } |
| 215 | + |
| 216 | + const tags = uniqueStrings(packageJson.tags || [], `${folderName}.tags`) |
| 217 | + .sort(compareStrings); |
| 218 | + const metadataText = [ |
| 219 | + folderName, |
| 220 | + packageJson.name, |
| 221 | + packageJson.nickname || '', |
| 222 | + packageJson.description || '', |
| 223 | + packageJson.description_en || '', |
| 224 | + ...(packageJson.keywords || []), |
| 225 | + ...tags, |
| 226 | + catalogEntry && catalogEntry.module ? catalogEntry.module : '', |
| 227 | + ...(catalogEntry && Array.isArray(catalogEntry.callables) ? catalogEntry.callables : []), |
| 228 | + ...(catalogEntry && Array.isArray(catalogEntry.methods) ? catalogEntry.methods : []), |
| 229 | + ...(catalogEntry && Array.isArray(catalogEntry.attributes) ? catalogEntry.attributes : []), |
| 230 | + ].join(' '); |
| 231 | + |
| 232 | + return { |
| 233 | + $schema: 'library-info-schema', |
| 234 | + name: packageJson.name.replace('@aily-project/', ''), |
| 235 | + displayName: packageJson.nickname || packageJson.name.replace('@aily-project/', ''), |
| 236 | + category: catalogEntry && catalogEntry.category |
| 237 | + ? catalogEntry.category |
| 238 | + : fallbackCategory(folderName, packageJson), |
| 239 | + subcategory: '', |
| 240 | + supportedCores, |
| 241 | + communication: detectCommunication(metadataText), |
| 242 | + voltage: [...new Set(voltage)], |
| 243 | + functions: [], |
| 244 | + hardwareType: detectHardwareType(metadataText, folderName), |
| 245 | + tags, |
| 246 | + }; |
| 247 | +} |
| 248 | + |
| 249 | +function parseArguments(args) { |
| 250 | + const regenerateAll = args.includes('--all'); |
| 251 | + const dryRun = args.includes('--dry-run'); |
| 252 | + const unknownOption = args.find((argument) => argument.startsWith('--') |
| 253 | + && argument !== '--all' |
| 254 | + && argument !== '--dry-run'); |
| 255 | + if (unknownOption) { |
| 256 | + throw new Error(`未知参数: ${unknownOption}`); |
| 257 | + } |
| 258 | + |
| 259 | + const libraries = args.filter((argument) => !argument.startsWith('--')); |
| 260 | + if (libraries.length > 1) { |
| 261 | + throw new Error('一次只能指定一个库目录'); |
| 262 | + } |
| 263 | + return { regenerateAll, dryRun, specificLibrary: libraries[0] }; |
| 264 | +} |
| 265 | + |
| 266 | +function generateInfoFiles(rootDir = ROOT, options = {}) { |
| 267 | + const catalog = loadCatalog(rootDir); |
| 268 | + const allPackages = getPublishablePackages(rootDir); |
| 269 | + const packages = options.specificLibrary |
| 270 | + ? allPackages.filter(({ folderName }) => folderName === options.specificLibrary) |
| 271 | + : allPackages; |
| 272 | + |
| 273 | + if (options.specificLibrary && packages.length === 0) { |
| 274 | + throw new Error(`没有找到可发布库目录: ${options.specificLibrary}`); |
| 275 | + } |
| 276 | + |
| 277 | + let written = 0; |
| 278 | + let skipped = 0; |
| 279 | + for (const { folderName, packageJson } of packages) { |
| 280 | + const infoPath = path.join(rootDir, folderName, 'info.json'); |
| 281 | + const info = buildInfo(folderName, packageJson, catalog.get(folderName)); |
| 282 | + if (options.dryRun) { |
| 283 | + continue; |
| 284 | + } |
| 285 | + if (!options.regenerateAll && fs.existsSync(infoPath)) { |
| 286 | + skipped += 1; |
| 287 | + continue; |
| 288 | + } |
| 289 | + writeJson(infoPath, info, 2); |
| 290 | + written += 1; |
| 291 | + } |
| 292 | + |
| 293 | + if (packages.length !== (options.dryRun ? packages.length : written + skipped)) { |
| 294 | + throw new Error(`info.json 处理数量与可发布包数量不一致`); |
| 295 | + } |
| 296 | + return { count: packages.length, skipped, written }; |
| 297 | +} |
| 298 | + |
| 299 | +function main() { |
| 300 | + const options = parseArguments(process.argv.slice(2)); |
| 301 | + const result = generateInfoFiles(ROOT, options); |
| 302 | + console.log(`info.json 处理完成:库 ${result.count},写入 ${result.written},跳过 ${result.skipped}`); |
| 303 | +} |
| 304 | + |
| 305 | +if (require.main === module) { |
| 306 | + try { |
| 307 | + main(); |
| 308 | + } catch (error) { |
| 309 | + console.error(error.message); |
| 310 | + process.exitCode = 1; |
| 311 | + } |
| 312 | +} |
| 313 | + |
| 314 | +module.exports = { |
| 315 | + buildInfo, |
| 316 | + detectCommunication, |
| 317 | + detectHardwareType, |
| 318 | + generateInfoFiles, |
| 319 | + loadCatalog, |
| 320 | + parseArguments, |
| 321 | +}; |
0 commit comments