wirestudio 0.17.2__tar.gz → 0.17.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wirestudio-0.17.2/wirestudio.egg-info → wirestudio-0.17.3}/PKG-INFO +1 -1
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_fleet.py +134 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_mcp_auth.py +42 -1
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/__init__.py +1 -1
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/api/app.py +77 -3
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/api/schemas.py +9 -1
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/fleet/client.py +44 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/mcp/__init__.py +6 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/mcp/auth.py +65 -14
- {wirestudio-0.17.2 → wirestudio-0.17.3/wirestudio.egg-info}/PKG-INFO +1 -1
- {wirestudio-0.17.2 → wirestudio-0.17.3}/LICENSE +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/README.md +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/pyproject.toml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/setup.cfg +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_active_design.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_agent.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_agent_stream.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_api.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_ascii_gen.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_build_backend.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_chirpstack.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_codec.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_compatibility.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_compile.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_coverage_baseline.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_design_events.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_designs.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_designs_seed.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_enclosure.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_enclosure_gate.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_enclosure_search.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_firmware_gen.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_footprints.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_intent.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_inventory.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_jlcpcb.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_kicad.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_kicad_fab.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_kicad_import.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_kicad_pcb.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_kicad_render.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_library_rtttl.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_lorawan.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_lorawan_api.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_mcp_resources.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_mcp_server.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_modules.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_pin_solver.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_recommend.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_schema.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_schematics_gate.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_security_cors.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_seed_onboard.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_serve.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_targets.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_tools_add_bus.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_tools_solve_pins.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_validate.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/tests/test_yaml_gen.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/agent/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/agent/agent.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/agent/session.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/agent/tools.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/api/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/api/__main__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/api/serve.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/csp/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/csp/compatibility.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/csp/pin_solver.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/designs/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/designs/active.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/designs/events.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/designs/seed.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/designs/store.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/enclosure/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/enclosure/openscad.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/enclosure/search.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/analog-node.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atom-echo.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atom-lite.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atom-matrix.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atoms3-lcd.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atoms3-lite.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atoms3-onboard.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atomu-onboard.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/atomu.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/attic-logger.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/awning-control.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/bl0906-mainmeter.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/blind-stepper.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/bluemotion.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/bluesonoff.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/button-toggles-light.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/c3-supermini.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/c6-supermini.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/desk-climate.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/desk-matrix.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/distance-sensor.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/encoder-drives-stepper.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/esp32-audio.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/esp32cam-wrover.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/esp32cam.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/flow-meter.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/garage-motion.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/grill-probe.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/guarded-button-light.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/keypad.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/kitchen-scale.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/led-display.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/lorawan-battery-uplink.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/motion-turns-on-light.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/multi-temp.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/nextion-thermostat.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/oled-knob.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/oled.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/parking-distance.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/presence-rf.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/rc522.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/room-climate.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/rs485-energy.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/s3-devkitc.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/s3-supermini.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/securitypanel.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/smart-plug-v1.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/smart-plug.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/subghz-rfid.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/t-beam.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/temp-above-turns-on-fan.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/temp-turns-on-fan.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/tft-touch.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/ttgo-lora32.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/tuya-smart-plug.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/wasserpir.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/weather-station.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/examples/wemosgps.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/fleet/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/generate/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/generate/__main__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/generate/ascii_gen.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/generate/yaml_gen.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/intent.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/inventory/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/inventory/check.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/inventory/csv_io.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/inventory/store.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/jlcpcb/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/jlcpcb/__main__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/jlcpcb/check.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/jlcpcb/client.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/fab.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/generator.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/import.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/importer.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/netlist.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/pcb.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/render.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/kicad/symbol_parser.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp01_1m.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-c3-devkitm-1.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-c3-supermini.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-c6-supermini.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-devkitc-v4.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-s3-devkitc-1.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-s3-supermini.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32-wrover-cam.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp32cam-ai-thinker.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/esp8285-1m.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/heltec-wifi-lora32-v2.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/heltec-wifi-lora32-v3.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/m5stack-atom-echo.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/m5stack-atom-matrix.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/m5stack-atom.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/m5stack-atoms3-lite.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/m5stack-atoms3.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/m5stack-atomu.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/nodemcu-32s.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/nodemcu-v2.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/ttgo-lora32-v1.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/ttgo-lora32-v2.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/ttgo-t-beam.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/boards/wemos-d1-mini.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/adc.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ads1115.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ads1115_channel.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/aht10.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/apa102.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/axp192.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/bh1750.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/bl0906.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/bme280.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/bmp180.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/bmp280.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/cc1101.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/cse7766.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/dht.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ds18b20.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/esp32_camera.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/esp32_rmt_led_strip.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/gpio_input.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/gpio_output.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/hc-sr04.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/hc-sr501.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/hlw8012.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/htu21d.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/hx711.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/i2s_microphone.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ili9xxx.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/lcd_pcf8574.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ld2420.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/max31855.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/max7219.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/max98357a.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/mcp23008.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/mcp23017.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/modbus.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/mpu6050.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/mpu6886.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/nextion.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/pcf8574.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/pulse_counter.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/rc522.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/rcwl-0516.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/rdm6300.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/remote_transmitter.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/rf_bridge.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/rotary_encoder.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/rtttl.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/sdm_meter.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/sht3xd.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ssd1306.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/st7789.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/sx127x.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/tm1638.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/tsl2561.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/tuya.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/tuya_sensor.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/tuya_switch.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/uart_gps.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/uln2003.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/vl53l0x.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/ws2812b.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/components/xpt2046.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/library/modules/oled-knob-13.yaml +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/mcp/server.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/model.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/recommend/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/recommend/recommender.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/schema/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/schema/design.schema.json +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/seed.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/base.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/build_backend.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/esphome.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/__init__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/__main__.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/api.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/build_local.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/chirpstack.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/codec.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/compile.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/firmware_gen.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/templates/main.cpp.j2 +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/targets/lorawan/templates/platformio.ini.j2 +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio/validate.py +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio.egg-info/SOURCES.txt +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio.egg-info/dependency_links.txt +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio.egg-info/entry_points.txt +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio.egg-info/requires.txt +0 -0
- {wirestudio-0.17.2 → wirestudio-0.17.3}/wirestudio.egg-info/top_level.txt +0 -0
|
@@ -43,6 +43,11 @@ class FakeFleetAddon:
|
|
|
43
43
|
# ({"id", "run_id", "state", "target", "finished_at"}) to drive the
|
|
44
44
|
# run-status verdict tests.
|
|
45
45
|
self.queue_jobs: list[dict] = []
|
|
46
|
+
# run_id -> {"app": bytes | None, "factory": bytes | None}; tests
|
|
47
|
+
# populate this to drive the firmware-artifact passthrough tests.
|
|
48
|
+
# None means "not built" -> the addon 404s (matches the fleet-side
|
|
49
|
+
# contract that 404 = not ready or unavailable).
|
|
50
|
+
self.firmware: dict[str, dict[str, bytes | None]] = {}
|
|
46
51
|
|
|
47
52
|
def transport(self) -> httpx.MockTransport:
|
|
48
53
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
@@ -110,6 +115,26 @@ class FakeFleetAddon:
|
|
|
110
115
|
"finished": bool(self.job_logs[run_id]["finished"]),
|
|
111
116
|
})
|
|
112
117
|
|
|
118
|
+
# GET /ui/api/jobs/{run_id}/firmware -> app image
|
|
119
|
+
# GET /ui/api/jobs/{run_id}/firmware/factory -> factory image
|
|
120
|
+
if method == "GET" and path.startswith("/ui/api/jobs/") and (
|
|
121
|
+
path.endswith("/firmware") or path.endswith("/firmware/factory")
|
|
122
|
+
):
|
|
123
|
+
kind = "factory" if path.endswith("/factory") else "app"
|
|
124
|
+
trim = "/firmware/factory" if kind == "factory" else "/firmware"
|
|
125
|
+
run_id = path[len("/ui/api/jobs/"):-len(trim)]
|
|
126
|
+
slot = self.firmware.get(run_id)
|
|
127
|
+
if slot is None:
|
|
128
|
+
return httpx.Response(404, json={"error": "unknown run_id"})
|
|
129
|
+
data = slot.get(kind)
|
|
130
|
+
if data is None:
|
|
131
|
+
return httpx.Response(404, json={"error": f"no {kind} image"})
|
|
132
|
+
return httpx.Response(
|
|
133
|
+
200,
|
|
134
|
+
content=data,
|
|
135
|
+
headers={"content-type": "application/octet-stream"},
|
|
136
|
+
)
|
|
137
|
+
|
|
113
138
|
return httpx.Response(404, json={"error": "not found"})
|
|
114
139
|
|
|
115
140
|
return httpx.MockTransport(handler)
|
|
@@ -488,6 +513,115 @@ async def test_fleet_job_log_unknown_run_id_returns_502(monkeypatch, tmp_path):
|
|
|
488
513
|
assert r.status_code == 502
|
|
489
514
|
|
|
490
515
|
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
# Build-artifact passthrough (fleet -> studio -> browser WebSerial)
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
async def test_get_firmware_returns_app_image_bytes():
|
|
521
|
+
addon = FakeFleetAddon()
|
|
522
|
+
addon.firmware["run-1"] = {"app": b"FAKE-APP-IMAGE", "factory": None}
|
|
523
|
+
fc = addon.make_client()
|
|
524
|
+
data = await fc.get_firmware("run-1")
|
|
525
|
+
assert data == b"FAKE-APP-IMAGE"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def test_get_firmware_factory_uses_factory_subpath():
|
|
529
|
+
addon = FakeFleetAddon()
|
|
530
|
+
addon.firmware["run-1"] = {
|
|
531
|
+
"app": b"APP",
|
|
532
|
+
"factory": b"BOOT+PART+APP",
|
|
533
|
+
}
|
|
534
|
+
fc = addon.make_client()
|
|
535
|
+
data = await fc.get_firmware("run-1", factory=True)
|
|
536
|
+
assert data == b"BOOT+PART+APP"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
async def test_get_firmware_unknown_run_id_raises():
|
|
540
|
+
addon = FakeFleetAddon()
|
|
541
|
+
fc = addon.make_client()
|
|
542
|
+
with pytest.raises(FleetUnavailable, match="not available"):
|
|
543
|
+
await fc.get_firmware("nope")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async def test_get_firmware_factory_missing_raises():
|
|
547
|
+
# The build produced an app image but the older ESPHome on the fleet
|
|
548
|
+
# didn't emit firmware-factory.bin -- the addon 404s factory while the
|
|
549
|
+
# app image is fine. The dialog falls back / surfaces the message.
|
|
550
|
+
addon = FakeFleetAddon()
|
|
551
|
+
addon.firmware["run-1"] = {"app": b"APP", "factory": None}
|
|
552
|
+
fc = addon.make_client()
|
|
553
|
+
with pytest.raises(FleetUnavailable, match="not available"):
|
|
554
|
+
await fc.get_firmware("run-1", factory=True)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
async def test_get_firmware_unconfigured_raises():
|
|
558
|
+
fc = FleetClient(base_url=None, token=None)
|
|
559
|
+
with pytest.raises(FleetUnavailable, match="missing"):
|
|
560
|
+
await fc.get_firmware("run-1")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
async def test_fleet_firmware_endpoint_unconfigured_returns_503(monkeypatch, tmp_path):
|
|
564
|
+
client = _make_client(monkeypatch, tmp_path, addon=None)
|
|
565
|
+
r = client.get("/fleet/jobs/run-1/firmware")
|
|
566
|
+
assert r.status_code == 503
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
async def test_fleet_firmware_endpoint_round_trips_app_image(monkeypatch, tmp_path):
|
|
570
|
+
addon = FakeFleetAddon()
|
|
571
|
+
addon.firmware["run-7"] = {"app": b"\x01\x02APP", "factory": None}
|
|
572
|
+
client = _make_client(monkeypatch, tmp_path, addon=addon)
|
|
573
|
+
r = client.get("/fleet/jobs/run-7/firmware")
|
|
574
|
+
assert r.status_code == 200
|
|
575
|
+
assert r.headers["content-type"] == "application/octet-stream"
|
|
576
|
+
assert r.headers["content-disposition"] == 'attachment; filename="run-7.bin"'
|
|
577
|
+
assert r.content == b"\x01\x02APP"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
async def test_fleet_firmware_endpoint_round_trips_factory_image(monkeypatch, tmp_path):
|
|
581
|
+
addon = FakeFleetAddon()
|
|
582
|
+
addon.firmware["run-7"] = {"app": b"app", "factory": b"\xe9\x00\x00FACTORY"}
|
|
583
|
+
client = _make_client(monkeypatch, tmp_path, addon=addon)
|
|
584
|
+
r = client.get("/fleet/jobs/run-7/firmware?factory=true")
|
|
585
|
+
assert r.status_code == 200
|
|
586
|
+
assert r.headers["content-disposition"] == 'attachment; filename="run-7-factory.bin"'
|
|
587
|
+
assert r.content == b"\xe9\x00\x00FACTORY"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def test_fleet_firmware_endpoint_unknown_run_id_returns_404(monkeypatch, tmp_path):
|
|
591
|
+
# 404 means "not ready yet / not built" -- the dialog uses this to keep
|
|
592
|
+
# waiting on the compile rather than treating it as a fleet outage (which
|
|
593
|
+
# would be 502).
|
|
594
|
+
addon = FakeFleetAddon()
|
|
595
|
+
client = _make_client(monkeypatch, tmp_path, addon=addon)
|
|
596
|
+
r = client.get("/fleet/jobs/ghost/firmware")
|
|
597
|
+
assert r.status_code == 404
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def test_fleet_firmware_endpoint_addon_unreachable_returns_502(monkeypatch, tmp_path):
|
|
601
|
+
# Addon returns a 5xx (here, simulated via missing handler -> 404 then
|
|
602
|
+
# mapped to "not available"). For a transport-level failure we'd return
|
|
603
|
+
# 502; cover that explicitly by pointing the client at a transport that
|
|
604
|
+
# always 500s.
|
|
605
|
+
def boom_handler(req: httpx.Request) -> httpx.Response:
|
|
606
|
+
if req.url.path.startswith("/ui/api/jobs/") and req.url.path.endswith(
|
|
607
|
+
"/firmware"
|
|
608
|
+
):
|
|
609
|
+
return httpx.Response(500, json={"error": "internal"})
|
|
610
|
+
return httpx.Response(404)
|
|
611
|
+
|
|
612
|
+
transport = httpx.MockTransport(boom_handler)
|
|
613
|
+
fc = FleetClient(
|
|
614
|
+
base_url="http://broken-fleet.local", token="tok-123", transport=transport,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
class _BrokenAddon:
|
|
618
|
+
def make_client(self): return fc
|
|
619
|
+
|
|
620
|
+
client = _make_client(monkeypatch, tmp_path, addon=_BrokenAddon())
|
|
621
|
+
r = client.get("/fleet/jobs/run-1/firmware")
|
|
622
|
+
assert r.status_code == 502
|
|
623
|
+
|
|
624
|
+
|
|
491
625
|
# ---------------------------------------------------------------------------
|
|
492
626
|
# SSE log relay
|
|
493
627
|
# ---------------------------------------------------------------------------
|
|
@@ -12,6 +12,9 @@ from starlette.routing import Route
|
|
|
12
12
|
from wirestudio.mcp.auth import (
|
|
13
13
|
DEFAULT_TOKEN_PATH,
|
|
14
14
|
BearerTokenMiddleware,
|
|
15
|
+
TokenManagedError,
|
|
16
|
+
TokenStore,
|
|
17
|
+
load_token_store,
|
|
15
18
|
resolve_token,
|
|
16
19
|
)
|
|
17
20
|
|
|
@@ -34,7 +37,8 @@ def _build_app(token: str) -> Starlette:
|
|
|
34
37
|
Route("/public", _ok),
|
|
35
38
|
]
|
|
36
39
|
)
|
|
37
|
-
|
|
40
|
+
store = TokenStore(token=token, path=Path("/unused"), env_managed=True)
|
|
41
|
+
app.add_middleware(BearerTokenMiddleware, store=store)
|
|
38
42
|
return app
|
|
39
43
|
|
|
40
44
|
|
|
@@ -115,6 +119,43 @@ def test_resolve_token_generates_and_persists(monkeypatch, tmp_path: Path):
|
|
|
115
119
|
assert resolve_token(token_path=file_path) == token
|
|
116
120
|
|
|
117
121
|
|
|
122
|
+
async def test_middleware_reads_rotated_token_without_restart(tmp_path: Path):
|
|
123
|
+
# Rotation updates the live store; the already-mounted middleware must
|
|
124
|
+
# validate against the new token, not the one captured at startup.
|
|
125
|
+
file_path = tmp_path / "mcp-token"
|
|
126
|
+
file_path.write_text("original")
|
|
127
|
+
store = TokenStore(token="original", path=file_path, env_managed=False)
|
|
128
|
+
|
|
129
|
+
app = Starlette(routes=[Route("/mcp", _ok)])
|
|
130
|
+
app.add_middleware(BearerTokenMiddleware, store=store)
|
|
131
|
+
transport = httpx.ASGITransport(app=app)
|
|
132
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
|
|
133
|
+
new_token = store.rotate()
|
|
134
|
+
r_old = await c.get("/mcp", headers={"Authorization": "Bearer original"})
|
|
135
|
+
r_new = await c.get("/mcp", headers={"Authorization": f"Bearer {new_token}"})
|
|
136
|
+
assert r_old.status_code == 401
|
|
137
|
+
assert r_new.status_code == 200
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_token_store_rotate_persists_new_token(tmp_path: Path):
|
|
141
|
+
file_path = tmp_path / "mcp-token"
|
|
142
|
+
store = load_token_store(token_path=file_path)
|
|
143
|
+
first = store.token
|
|
144
|
+
rotated = store.rotate()
|
|
145
|
+
assert rotated != first
|
|
146
|
+
assert store.token == rotated
|
|
147
|
+
assert file_path.read_text() == rotated
|
|
148
|
+
assert file_path.stat().st_mode & 0o777 == 0o600
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_token_store_rotate_refuses_env_managed(monkeypatch, tmp_path: Path):
|
|
152
|
+
monkeypatch.setenv("WIRESTUDIO_MCP_TOKEN", "from-env")
|
|
153
|
+
store = load_token_store(token_path=tmp_path / "mcp-token")
|
|
154
|
+
assert store.env_managed is True
|
|
155
|
+
with pytest.raises(TokenManagedError):
|
|
156
|
+
store.rotate()
|
|
157
|
+
|
|
158
|
+
|
|
118
159
|
def test_default_token_path_is_under_user_config():
|
|
119
160
|
# Anchored to ~/.config/wirestudio so operators know where to look
|
|
120
161
|
# without grepping the source.
|
|
@@ -22,7 +22,12 @@ from slowapi.errors import RateLimitExceeded
|
|
|
22
22
|
import os
|
|
23
23
|
from pydantic import ValidationError
|
|
24
24
|
|
|
25
|
-
from wirestudio.mcp import
|
|
25
|
+
from wirestudio.mcp import (
|
|
26
|
+
BearerTokenMiddleware,
|
|
27
|
+
TokenManagedError,
|
|
28
|
+
build_mcp_server,
|
|
29
|
+
load_token_store,
|
|
30
|
+
)
|
|
26
31
|
|
|
27
32
|
from wirestudio import __version__
|
|
28
33
|
from wirestudio.agent.agent import is_available as agent_available, run_turn, stream_turn_events
|
|
@@ -42,6 +47,7 @@ from wirestudio.api.schemas import (
|
|
|
42
47
|
InventoryCheckRequest,
|
|
43
48
|
InventoryCheckResponse,
|
|
44
49
|
InventoryEntryModel,
|
|
50
|
+
McpTokenResponse,
|
|
45
51
|
ModuleSummary,
|
|
46
52
|
FleetJobLogResponse,
|
|
47
53
|
FleetJobStatus,
|
|
@@ -1212,6 +1218,53 @@ def create_app(
|
|
|
1212
1218
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
1213
1219
|
)
|
|
1214
1220
|
|
|
1221
|
+
@app.get("/fleet/jobs/{run_id}/firmware", tags=["fleet"])
|
|
1222
|
+
async def fleet_job_firmware(run_id: str, factory: bool = False) -> Response:
|
|
1223
|
+
"""Passthrough for the fleet's compiled-firmware artifact -- the
|
|
1224
|
+
studio fetches it server-side with FLEET_TOKEN and streams the
|
|
1225
|
+
bytes to the browser's WebSerial flasher (no token in the browser).
|
|
1226
|
+
|
|
1227
|
+
``?factory=true`` returns the merged bootloader+partitions+app
|
|
1228
|
+
image for flashing a blank board at offset 0x0 (paired with
|
|
1229
|
+
``eraseAll: true`` on the browser side); default is the app image
|
|
1230
|
+
for an NVS-preserving re-flash. Mirrors the standalone path's
|
|
1231
|
+
``/lorawan/firmware/{cache_key}[/factory]`` so the WebSerial flow
|
|
1232
|
+
on the external-component path can reuse ``lib/flash.ts`` unchanged.
|
|
1233
|
+
|
|
1234
|
+
Returns 404 when the run hasn't finished, has aged out of the
|
|
1235
|
+
addon's tracking, or the build didn't produce the requested image
|
|
1236
|
+
type; 503 when fleet credentials are missing; 502 when the addon
|
|
1237
|
+
is reachable but the call failed (e.g. the upstream artifact
|
|
1238
|
+
endpoint isn't implemented yet -- see
|
|
1239
|
+
``docs/lorawan/fleet-firmware-flash.md``).
|
|
1240
|
+
"""
|
|
1241
|
+
fc = make_fleet()
|
|
1242
|
+
if not fc.is_configured():
|
|
1243
|
+
raise HTTPException(
|
|
1244
|
+
status_code=503,
|
|
1245
|
+
detail="fleet not configured (set FLEET_URL and FLEET_TOKEN)",
|
|
1246
|
+
)
|
|
1247
|
+
try:
|
|
1248
|
+
data = await fc.get_firmware(run_id, factory=factory)
|
|
1249
|
+
except FleetUnavailable as e:
|
|
1250
|
+
# The client raises FleetUnavailable on both 404 (firmware not
|
|
1251
|
+
# available -> the dialog stops the flash flow with a clear
|
|
1252
|
+
# message) and transport failures (the addon being down).
|
|
1253
|
+
# Preserve the 404 vs 502 distinction the addon emitted so the
|
|
1254
|
+
# browser can tell "not ready yet" from "fleet is down".
|
|
1255
|
+
msg = str(e)
|
|
1256
|
+
if "not available" in msg:
|
|
1257
|
+
raise HTTPException(status_code=404, detail=msg) from e
|
|
1258
|
+
raise HTTPException(status_code=502, detail=msg) from e
|
|
1259
|
+
suffix = "-factory" if factory else ""
|
|
1260
|
+
return Response(
|
|
1261
|
+
content=data,
|
|
1262
|
+
media_type="application/octet-stream",
|
|
1263
|
+
headers={
|
|
1264
|
+
"Content-Disposition": f'attachment; filename="{run_id}{suffix}.bin"',
|
|
1265
|
+
},
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1215
1268
|
@app.get("/agent/status", tags=["agent"])
|
|
1216
1269
|
def agent_status() -> dict:
|
|
1217
1270
|
ok, reason = agent_available()
|
|
@@ -1307,11 +1360,32 @@ def create_app(
|
|
|
1307
1360
|
h.strip() for h in allowed_hosts.split(",") if h.strip()
|
|
1308
1361
|
]
|
|
1309
1362
|
token_path_env = os.environ.get("WIRESTUDIO_MCP_TOKEN_PATH")
|
|
1310
|
-
|
|
1363
|
+
token_store = load_token_store(
|
|
1311
1364
|
token_path=Path(token_path_env) if token_path_env else None,
|
|
1312
1365
|
)
|
|
1366
|
+
|
|
1367
|
+
# Token-management endpoints for the web UI. Registered before the
|
|
1368
|
+
# catch-all mount below so FastAPI serves them directly -- they sit on
|
|
1369
|
+
# the unauthenticated API surface (the bearer token only gates /mcp),
|
|
1370
|
+
# which is required so the UI can show the token to a user who doesn't
|
|
1371
|
+
# have it yet.
|
|
1372
|
+
@app.get("/mcp/token", response_model=McpTokenResponse, tags=["mcp"])
|
|
1373
|
+
def get_mcp_token() -> McpTokenResponse:
|
|
1374
|
+
return McpTokenResponse(
|
|
1375
|
+
token=token_store.token,
|
|
1376
|
+
managed="env" if token_store.env_managed else "file",
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
@app.post("/mcp/token/rotate", response_model=McpTokenResponse, tags=["mcp"])
|
|
1380
|
+
def rotate_mcp_token() -> McpTokenResponse:
|
|
1381
|
+
try:
|
|
1382
|
+
new_token = token_store.rotate()
|
|
1383
|
+
except TokenManagedError as e:
|
|
1384
|
+
raise HTTPException(status_code=409, detail=str(e)) from e
|
|
1385
|
+
return McpTokenResponse(token=new_token, managed="file")
|
|
1386
|
+
|
|
1313
1387
|
mcp_app = mcp_server.streamable_http_app()
|
|
1314
|
-
mcp_app.add_middleware(BearerTokenMiddleware,
|
|
1388
|
+
mcp_app.add_middleware(BearerTokenMiddleware, store=token_store)
|
|
1315
1389
|
app.mount("/", mcp_app)
|
|
1316
1390
|
|
|
1317
1391
|
return app
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"""
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
-
from typing import Optional
|
|
6
|
+
from typing import Literal, Optional
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel, ConfigDict, Field
|
|
9
9
|
|
|
@@ -12,6 +12,14 @@ class _S(BaseModel):
|
|
|
12
12
|
model_config = ConfigDict(extra="forbid")
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
class McpTokenResponse(_S):
|
|
16
|
+
token: str = Field(description="Current MCP bearer token.")
|
|
17
|
+
managed: Literal["file", "env"] = Field(
|
|
18
|
+
description="Token source: 'file' (rotatable here) or 'env' (set via "
|
|
19
|
+
"WIRESTUDIO_MCP_TOKEN, read-only)."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
15
23
|
class BoardSummary(_S):
|
|
16
24
|
id: str = Field(description="Board library id, e.g. 'esp32-devkitc-v4'.")
|
|
17
25
|
name: str = Field(description="Human-readable board name.")
|
|
@@ -282,6 +282,50 @@ class FleetClient:
|
|
|
282
282
|
]
|
|
283
283
|
return RunStatus(run_id=run_id, verdict=_verdict(jobs), jobs=jobs)
|
|
284
284
|
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Build-artifact fetch
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
async def get_firmware(
|
|
290
|
+
self, run_id: str, *, factory: bool = False
|
|
291
|
+
) -> bytes:
|
|
292
|
+
"""Fetch the compiled firmware bytes for a finished build.
|
|
293
|
+
|
|
294
|
+
Mirrors the addon's artifact endpoint at
|
|
295
|
+
``GET /ui/api/jobs/{run_id}/firmware[/factory]`` (the upstream
|
|
296
|
+
half of the headless-device flash story -- see
|
|
297
|
+
``docs/lorawan/fleet-firmware-flash.md``). The browser never holds
|
|
298
|
+
``FLEET_TOKEN``; this is the studio-side passthrough that fetches
|
|
299
|
+
with the server token and re-streams the bytes to the WebSerial
|
|
300
|
+
flasher via the matching studio route.
|
|
301
|
+
|
|
302
|
+
``factory=False`` (default) fetches the app image (re-flash that
|
|
303
|
+
preserves NVS); ``factory=True`` fetches the merged factory image
|
|
304
|
+
for blank-board flashing at offset 0x0. 404 -> ``FleetUnavailable``
|
|
305
|
+
the same way ``get_job_log`` handles unknown run_ids, so the UI
|
|
306
|
+
can stop polling.
|
|
307
|
+
|
|
308
|
+
The upstream endpoint does not exist yet at the time of writing
|
|
309
|
+
(it's the scoped fleet half); calls will 502 via the matching
|
|
310
|
+
studio route until the addon ships it. That gates the dialog's
|
|
311
|
+
"Flash via WebSerial" button on the upstream rollout without
|
|
312
|
+
changing this client surface later.
|
|
313
|
+
"""
|
|
314
|
+
if not self.is_configured():
|
|
315
|
+
raise FleetUnavailable("FLEET_URL or FLEET_TOKEN missing")
|
|
316
|
+
suffix = "/factory" if factory else ""
|
|
317
|
+
async with self._client() as c:
|
|
318
|
+
resp = await c.get(f"/ui/api/jobs/{run_id}/firmware{suffix}")
|
|
319
|
+
if resp.status_code == 404:
|
|
320
|
+
raise FleetUnavailable(
|
|
321
|
+
f"firmware artifact not available for run_id {run_id!r}"
|
|
322
|
+
)
|
|
323
|
+
if resp.status_code >= 400:
|
|
324
|
+
raise FleetUnavailable(
|
|
325
|
+
f"firmware fetch failed: http {resp.status_code} {resp.text}"
|
|
326
|
+
)
|
|
327
|
+
return resp.content
|
|
328
|
+
|
|
285
329
|
# ------------------------------------------------------------------
|
|
286
330
|
# Internals
|
|
287
331
|
# ------------------------------------------------------------------
|
|
@@ -8,6 +8,9 @@ into the existing FastAPI app at `/mcp` over the Streamable HTTP transport.
|
|
|
8
8
|
from wirestudio.mcp.auth import (
|
|
9
9
|
DEFAULT_TOKEN_PATH,
|
|
10
10
|
BearerTokenMiddleware,
|
|
11
|
+
TokenManagedError,
|
|
12
|
+
TokenStore,
|
|
13
|
+
load_token_store,
|
|
11
14
|
resolve_token,
|
|
12
15
|
)
|
|
13
16
|
from wirestudio.mcp.server import build_mcp_server
|
|
@@ -15,6 +18,9 @@ from wirestudio.mcp.server import build_mcp_server
|
|
|
15
18
|
__all__ = [
|
|
16
19
|
"BearerTokenMiddleware",
|
|
17
20
|
"DEFAULT_TOKEN_PATH",
|
|
21
|
+
"TokenManagedError",
|
|
22
|
+
"TokenStore",
|
|
18
23
|
"build_mcp_server",
|
|
24
|
+
"load_token_store",
|
|
19
25
|
"resolve_token",
|
|
20
26
|
]
|
|
@@ -21,32 +21,83 @@ DEFAULT_TOKEN_PATH = Path(os.path.expanduser("~/.config/wirestudio/mcp-token"))
|
|
|
21
21
|
ENV_VAR = "WIRESTUDIO_MCP_TOKEN"
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
class TokenManagedError(RuntimeError):
|
|
25
|
+
"""Raised when rotation is attempted on an externally-managed token.
|
|
26
|
+
|
|
27
|
+
When the token comes from `WIRESTUDIO_MCP_TOKEN` we can't rewrite it -- the
|
|
28
|
+
operator owns it (k8s Secret / sops). Rotating would only diverge the live
|
|
29
|
+
value from the secret, so we refuse.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _generate_and_persist(path: Path) -> str:
|
|
34
|
+
token = secrets.token_urlsafe(32)
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
path.write_text(token)
|
|
37
|
+
path.chmod(0o600)
|
|
38
|
+
return token
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TokenStore:
|
|
42
|
+
"""Holds the live MCP bearer token so rotation takes effect without a restart.
|
|
43
|
+
|
|
44
|
+
`BearerTokenMiddleware` reads `.token` on every request rather than
|
|
45
|
+
capturing the value at construction time. `env_managed` is True when the
|
|
46
|
+
token came from the env var, in which case `rotate()` raises.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, *, token: str, path: Path, env_managed: bool) -> None:
|
|
50
|
+
self._token = token
|
|
51
|
+
self._path = path
|
|
52
|
+
self.env_managed = env_managed
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def token(self) -> str:
|
|
56
|
+
return self._token
|
|
57
|
+
|
|
58
|
+
def rotate(self) -> str:
|
|
59
|
+
if self.env_managed:
|
|
60
|
+
raise TokenManagedError(
|
|
61
|
+
f"token is set via {ENV_VAR}; rotate it by updating that secret, not here"
|
|
62
|
+
)
|
|
63
|
+
self._token = _generate_and_persist(self._path)
|
|
64
|
+
logger.info("Rotated MCP token; persisted to %s", self._path)
|
|
65
|
+
return self._token
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_token_store(
|
|
25
69
|
*,
|
|
26
70
|
env_var: str = ENV_VAR,
|
|
27
71
|
token_path: Path | None = None,
|
|
28
|
-
) ->
|
|
29
|
-
"""
|
|
72
|
+
) -> TokenStore:
|
|
73
|
+
"""Resolve the MCP bearer token into a mutable store.
|
|
30
74
|
|
|
31
75
|
The env var wins if set (operators using k8s Secrets / sops set this and
|
|
32
76
|
the token-file path is ignored). Otherwise the persisted file is read.
|
|
33
77
|
Otherwise a fresh token is generated, persisted with mode 0600, and the
|
|
34
78
|
path is logged at INFO so the operator knows where to copy it from.
|
|
35
79
|
"""
|
|
80
|
+
path = token_path or DEFAULT_TOKEN_PATH
|
|
81
|
+
|
|
36
82
|
env_token = os.environ.get(env_var)
|
|
37
83
|
if env_token:
|
|
38
|
-
return env_token.strip()
|
|
84
|
+
return TokenStore(token=env_token.strip(), path=path, env_managed=True)
|
|
39
85
|
|
|
40
|
-
path = token_path or DEFAULT_TOKEN_PATH
|
|
41
86
|
if path.exists():
|
|
42
|
-
return path.read_text().strip()
|
|
87
|
+
return TokenStore(token=path.read_text().strip(), path=path, env_managed=False)
|
|
43
88
|
|
|
44
|
-
token =
|
|
45
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
-
path.write_text(token)
|
|
47
|
-
path.chmod(0o600)
|
|
89
|
+
token = _generate_and_persist(path)
|
|
48
90
|
logger.info("Generated MCP token; copy it from %s", path)
|
|
49
|
-
return token
|
|
91
|
+
return TokenStore(token=token, path=path, env_managed=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_token(
|
|
95
|
+
*,
|
|
96
|
+
env_var: str = ENV_VAR,
|
|
97
|
+
token_path: Path | None = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Return the current MCP bearer token. See `load_token_store`."""
|
|
100
|
+
return load_token_store(env_var=env_var, token_path=token_path).token
|
|
50
101
|
|
|
51
102
|
|
|
52
103
|
class BearerTokenMiddleware:
|
|
@@ -59,9 +110,9 @@ class BearerTokenMiddleware:
|
|
|
59
110
|
only gates the MCP endpoint, not the rest of the API.
|
|
60
111
|
"""
|
|
61
112
|
|
|
62
|
-
def __init__(self, app: ASGIApp, *,
|
|
113
|
+
def __init__(self, app: ASGIApp, *, store: TokenStore, path_prefix: str = "/mcp") -> None:
|
|
63
114
|
self.app = app
|
|
64
|
-
self.
|
|
115
|
+
self._store = store
|
|
65
116
|
# Regex match: prefix as a path component (preceded by "/" or start,
|
|
66
117
|
# followed by "/" or end). We can't compare scope[path] literally
|
|
67
118
|
# because Starlette's nested Mount('/') doesn't strip path
|
|
@@ -88,7 +139,7 @@ class BearerTokenMiddleware:
|
|
|
88
139
|
auth = headers.get(b"authorization", b"").decode("latin-1", errors="ignore")
|
|
89
140
|
prefix = "Bearer "
|
|
90
141
|
if not auth.startswith(prefix) or not secrets.compare_digest(
|
|
91
|
-
auth[len(prefix):], self.
|
|
142
|
+
auth[len(prefix):], self._store.token
|
|
92
143
|
):
|
|
93
144
|
await _send_401(send)
|
|
94
145
|
return
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|