Compare commits

..

3 commits

Author SHA1 Message Date
Justin Cooper
5d42842851 update data to match newly normalized data from both apis 2025-08-20 17:23:45 -05:00
Justin Cooper
9a3075da9c Merge branch 'main' into air-quality 2025-08-20 17:01:15 -05:00
Justin Cooper
3b9df81a56 initial air quality block 2025-07-24 16:27:53 -05:00
18 changed files with 514 additions and 534 deletions

View file

@ -41,9 +41,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Export Block Images
run: npm run export:block-images
- name: Export Block Definitions to Markdown
run: npm run docs:export

View file

@ -20,18 +20,9 @@ Node v22.x is expected, but other versions may work.
git clone https://github.com/adafruit/io-actions
cd io-actions
npm i
npm run export:block-images # run once, and whenever images need updating
npm start
```
Now 2 processes should be running:
- a builder process that:
- does a full build of the docs from the app files
- watches app files for changes and updates matching docs
- the docs dev server where you can see your changes rendered live as you save them
When you're done working simply press `CTRL + C` to terminate the processes.
### Exporting
Export a Blockly application:

View file

@ -0,0 +1,126 @@
import airQualityMixin from "./air_quality_mixin.js"
export default {
type: "airQuality",
bytecodeKey: "airQuality",
name: "Air Quality",
colour: 360,
ioPlus: true,
description: "Fetch current or forecast air quality conditions at the specified location using Open-Meteo Air Quality API.",
connections: {
mode: "value",
output: "expression",
},
mixins: [
'replaceDropdownOptions',
{ airQualityMixin }
],
extensions: {
prepareAirQuality: ({ block, observeData, data: { airQualityLocationOptions } }) => {
// populate air quality locations
if(!airQualityLocationOptions?.length) {
airQualityLocationOptions = [[ "No locations! Visit Power-Ups -> Air Quality", "" ]]
block.setEnabled(false)
} else if(airQualityLocationOptions[0][1] != "") {
airQualityLocationOptions.unshift([ "Select Location", "" ])
}
block.replaceDropdownOptions("POWER_UP_ID", airQualityLocationOptions)
// skip the rest if we're in the toolbox
if(block.isInFlyout) { return }
// yield so fields can populate, flags can be set
setTimeout(() => {
// nope out for insertion markers
if(block.isInsertionMarker()) { return }
// auto-disable block, if necessary
block.setEnabledByLocation()
// react to incoming forecast data
const unobserve = observeData('currentAirQualityByLocation', (newData = {}) => {
// if this block is disposed, clean up this listener
if (block.isDisposed()) { unobserve(); return }
// update the reference to the injected/updated extension data
block.currentAirQualityByLocation = newData
// re-run the things that use the data
block.refreshPropertyOptions({})
})
}, 1)
}
},
template: `
Air Quality |CENTER
At: %POWER_UP_ID
When: %AIR_QUALITY_TIME
Metric: %AIR_QUALITY_PROPERTY
%AIR_QUALITY_PROPERTY_HELP
`,
fields: {
POWER_UP_ID: {
description: "Select a location from those defined by the Air Quality Power-Up",
options: [
[ "Loading locations...", "" ],
]
},
AIR_QUALITY_TIME: {
description: "Select which kind of forecast to query",
options: [
[ "Now", "current" ],
[ "Today", "forecast_today" ],
[ "Tomorrow", "forecast_tomorrow" ],
[ "In 2 days", "forecast_day_2" ],
[ "In 3 days", "forecast_day_3" ],
[ "In 4 days", "forecast_day_4" ],
[ "In 5 days", "forecast_day_5" ]
]
},
AIR_QUALITY_PROPERTY: {
description: "Select which metric of the air quality to use.",
label: ""
},
AIR_QUALITY_PROPERTY_HELP: {
label: ""
},
},
generators: {
json: block => {
const
powerUpId = parseInt(block.getFieldValue('POWER_UP_ID'), 10),
airQualityTime = block.getFieldValue('AIR_QUALITY_TIME'),
airQualityProperty = block.getFieldValue('AIR_QUALITY_PROPERTY'),
payload = { airQuality: {
powerUpId, airQualityTime, airQualityProperty
}}
return [ JSON.stringify(payload), 0 ]
}
},
regenerators: {
json: blockObject => {
const payload = blockObject.airQuality
return {
type: "airQuality",
fields: {
POWER_UP_ID: String(payload.powerUpId),
AIR_QUALITY_TIME: payload.airQualityTime,
AIR_QUALITY_PROPERTY: payload.airQualityProperty,
}
}
}
}
}

View file

@ -0,0 +1,281 @@
// helpers for the air quality block
// simplifies juggling the air quality api properties by location and period
export default {
onchange: function({ blockId, type, name, element, newValue }) {
// only change events, for this block, unless it is a marker
if(this.id !== blockId || type !== "change" || this.isInsertionMarker()) { return }
// double-check anytime this block gets enabled (disableOrphans)
if(element === "disabled" && newValue === false) {
this.setEnabledByLocation()
} else if(element === "field") {
if (name === "POWER_UP_ID") {
// enable/disabled based on location change
this.setEnabledByLocation()
this.refreshPropertyOptions({ locationKey: newValue })
} else if (name === "AIR_QUALITY_TIME") {
// update available metrics when forecast changes
this.refreshPropertyOptions({ timeKey: newValue })
} else if (name === "AIR_QUALITY_PROPERTY") {
// update help text when the metric changes
this.updateHelpTextForAirQualityProperty({ propertyKey: newValue })
}
}
},
setEnabledByLocation: function() {
// must have a location and a parent (copacetic with disableOrphans)
if(this.getFieldValue("POWER_UP_ID") === "" || !this.getParent()) {
this.disabled || this.setEnabled(false)
} else {
this.disabled && this.setEnabled(true)
}
},
// helper to humanize camelCase and snake_case strings
keyToLabel: function(key) {
// Handle special cases first
const specialCases = {
'aqi': 'AQI',
'category_key': 'Category Key',
'category_label': 'Category',
'category_color': 'Category Color',
'category_min': 'Category Min',
'category_max': 'Category Max',
'health_description': 'Health Description',
'health_recommendation': 'Health Recommendation',
'health_sensitive_groups': 'Sensitive Groups',
'primary_pollutant': 'Primary Pollutant',
'pm2_5': 'PM2.5',
'pm10': 'PM10',
'o3': 'Ozone (O3)',
'no2': 'Nitrogen Dioxide (NO2)',
'so2': 'Sulfur Dioxide (SO2)',
'co': 'Carbon Monoxide (CO)',
'reporting_area': 'Reporting Area'
}
if (specialCases[key]) {
return specialCases[key]
}
const label = key
// replace underscores with spaces
.replaceAll('_', '\u00A0')
// capitalize the first letter of each word (handles both spaces and non-breaking spaces)
.replace(/(^|[\s\u00A0])[a-z]/g, (match) => match.toUpperCase())
return label
},
keyToHelpObject: function(key) {
const keyWithoutDayPart = key.split(":").pop()
return this.HELP_TEXT_BY_PROP[keyWithoutDayPart] || {}
},
keyToTooltip: function(key) {
const { description="" } = this.keyToHelpObject(key)
return `${this.keyToLabel(key)}:\n ${description}`
},
keyToCurrent: function(key, { timeKey=null, locationKey=null }) {
const
locationId = locationKey || this.getFieldValue("POWER_UP_ID"),
forecast = timeKey || this.getFieldValue("AIR_QUALITY_TIME"),
currentValue = this.currentAirQualityByLocation[locationId]?.[forecast]?.[key]
// return a current value with "Now" label, if found
if(currentValue !== undefined && currentValue !== null) {
return `Now:\u00A0${currentValue}`
}
// use example value with "e.g." label otherwise
const { example="unknown" } = this.keyToHelpObject(key)
return `e.g.\u00A0${example}`
},
refreshPropertyOptions: function({ timeKey=null, locationKey=null }) {
timeKey = timeKey || this.getFieldValue("AIR_QUALITY_TIME")
if(!timeKey) {
// If no timeKey is available, default to 'current'
timeKey = 'current'
}
let optionKeys
if(timeKey === 'current') {
optionKeys = this.CURRENT_PROPS
} else if(timeKey.match(/forecast_/)) {
optionKeys = this.DAILY_PROPS
} else {
throw new Error(`[mixins.airQuality] timeKey not recognized: ${timeKey}`)
}
// TODO: is there a way to add tooltips for each option as well?
const propertyOptions = optionKeys.reduce((acc, key) => {
const
name = this.keyToLabel(key),
current = this.keyToCurrent(key, { timeKey, locationKey }),
label = `${name}\u00A0(${current})`
acc.push([ label, key ])
return acc
}, [])
// update the property options and the property help
this.replaceDropdownOptions("AIR_QUALITY_PROPERTY", propertyOptions)
this.updateHelpTextForAirQualityProperty({ timeKey, locationKey })
},
updateHelpTextForAirQualityProperty: function({ propertyKey=null, timeKey=null, locationKey=null }) {
const
propertyField = this.getField("AIR_QUALITY_PROPERTY"),
helpField = this.getField("AIR_QUALITY_PROPERTY_HELP")
if(!propertyKey) {
propertyKey = propertyField.getValue()
}
const
helpText = this.keyToTooltip(propertyKey),
current = this.keyToCurrent(propertyKey, { timeKey, locationKey })
// set a metric tooltip on dropdown and help text
propertyField.setTooltip(helpText)
helpField.setTooltip(helpText)
// update the help text with examples for this metric
helpField.setValue(current)
},
// a placeholder for the incoming preview data from live air quality requests
currentAirQualityByLocation: {},
CURRENT_PROPS: [
'aqi',
'category_key',
'category_label',
'category_color',
'category_min',
'category_max',
'health_description',
'health_recommendation',
'health_sensitive_groups',
'pm2_5',
'pm10',
'o3',
'no2',
'so2',
'co',
'primary_pollutant',
'reporting_area',
'state',
'latitude',
'longitude'
],
DAILY_PROPS: [
'aqi',
'category_key',
'category_label',
'category_color',
'category_min',
'category_max',
'health_description',
'health_recommendation',
'health_sensitive_groups',
'primary_pollutant'
],
HELP_TEXT_BY_PROP: {
aqi: {
example: "75",
description: "Air Quality Index value. Ranges from 0-50 (good), 51-100 (moderate), 101-150 (unhealthy for sensitive groups), 151-200 (unhealthy), 201-300 (very unhealthy) and 301-500 (hazardous)."
},
category_key: {
example: "moderate",
description: "Category key for the air quality level (e.g., good, moderate, unhealthy_sensitive, unhealthy, very_unhealthy, hazardous)."
},
category_label: {
example: "Moderate",
description: "Human-readable category label for the air quality level."
},
category_color: {
example: "#ffff00",
description: "Hex color code representing the air quality category for visual display."
},
category_min: {
example: "51",
description: "Minimum AQI value for this air quality category."
},
category_max: {
example: "100",
description: "Maximum AQI value for this air quality category."
},
health_description: {
example: "Air quality is acceptable...",
description: "General health description for the current air quality conditions."
},
health_recommendation: {
example: "Unusually sensitive people should consider...",
description: "Health recommendations for the current air quality conditions."
},
health_sensitive_groups: {
example: "Children, Older adults",
description: "Groups of people who are more sensitive to the current air quality conditions."
},
primary_pollutant: {
example: "pm2_5",
description: "The primary pollutant contributing to the air quality index (e.g., pm2_5, pm10, o3, no2, so2, co)."
},
pm2_5: {
example: "15.2",
description: "Fine particulate matter with diameter smaller than 2.5 micrometers, measured in μg/m³. Major health concern for respiratory and cardiovascular systems."
},
pm10: {
example: "23.1",
description: "Particulate matter with diameter smaller than 10 micrometers, measured in μg/m³. Can cause respiratory irritation and reduced lung function."
},
o3: {
example: "45.0",
description: "Ground-level ozone concentration, measured in μg/m³. Can cause breathing problems, especially during physical activity outdoors."
},
no2: {
example: "12.4",
description: "Nitrogen dioxide concentration, measured in μg/m³. Can aggravate respiratory diseases and reduce lung function."
},
so2: {
example: "3.1",
description: "Sulfur dioxide concentration, measured in μg/m³. Can cause respiratory symptoms and worsen asthma and heart disease."
},
co: {
example: "0.8",
description: "Carbon monoxide concentration, measured in ppm. Reduces oxygen delivery to organs and tissues."
},
reporting_area: {
example: "Boston",
description: "The geographic area or city where this air quality data was measured."
},
state: {
example: "MA",
description: "The state or region abbreviation where this air quality measurement was taken."
},
latitude: {
example: "42.3601",
description: "Latitude coordinate of the monitoring station location."
},
longitude: {
example: "-71.0589",
description: "Longitude coordinate of the monitoring station location."
}
}
}

View file

@ -5,387 +5,46 @@ export default {
name: "Join Text",
colour: 180,
inputsInline: true,
description: `
Join two pieces of text into one combined string. Perfect for building dynamic messages, creating formatted outputs, or combining data from different sources into readable text.
## What is Join Text?
Think of Join Text like using a plus sign (+) to glue two pieces of text together. Just like "Hello" + "World" becomes "HelloWorld", this block combines any two text values into one.
## How It Works
::: info
**Input A** + **Input B** **Combined Output**
Example: \`"Hello "\` + \`"World"\`\`"Hello World"\`
:::
The Join Text block takes two inputs (A and B) and combines them into a single text output:
- **Input A**: First piece of text
- **Input B**: Second piece of text
- **Output**: A + B (combined text)
::: warning Important!
The texts are joined directly with **no space** between them. If you want a space, you need to add it yourself!
:::
## Basic Examples
### Simple Text Joining
| Input A | Input B | Output |
|---------|---------|--------|
| \`"Hello"\` | \`"World"\` | \`"HelloWorld"\` |
| \`"Hello "\` | \`"World"\` | \`"Hello World"\` |
| \`"Temperature: "\` | \`"72°F"\` | \`"Temperature: 72°F"\` |
### Adding Spaces and Punctuation
| Input A | Input B | Output |
|---------|---------|--------|
| \`"Good"\` | \`" morning!"\` | \`"Good morning!"\` |
| \`"Status:"\` | \`" Active"\` | \`"Status: Active"\` |
| \`"User"\` | \`"#1234"\` | \`"User#1234"\` |
## IoT Use Cases
### 🏷 Creating Labels with Values
**Temperature Label:**
\`\`\`js
A: "Kitchen Temp: "
B: {{ feeds['sensors.kitchen_temp'].value }}
// Output: "Kitchen Temp: 72.5"
\`\`\`
**Device Status:**
\`\`\`js
A: "Door is "
B: {{ feeds['security.door'].value }}
// Output: "Door is OPEN" or "Door is CLOSED"
\`\`\`
### 📊 Building Status Messages
**Battery Level:**
\`\`\`js
A: "Battery at "
B: {{ vars.battery_percent }}%
// Output: "Battery at 85%"
\`\`\`
**Sensor with Units:**
\`\`\`js
A: {{ feeds['sensors.humidity'].value }}
B: "% humidity"
// Output: "65% humidity"
\`\`\`
### 👤 Personalizing Messages
**Welcome Message:**
\`\`\`js
A: "Welcome back, "
B: {{ user.name }}
// Output: "Welcome back, John Smith"
\`\`\`
**Custom Greeting:**
\`\`\`js
A: {{ vars.greeting }}
B: {{ user.username }}
// Output: "Hello, jsmith123"
\`\`\`
### 🔗 Creating URLs and Paths
::: code-group
description: "Join two pieces of text into one.",
\`\`\`js [API Endpoint]
A: "https://api.example.com/sensors/"
B: {{ vars.sensor_id }}
// Output: "https://api.example.com/sensors/temp_01"
\`\`\`
\`\`\`js [File Path]
A: "/data/"
B: {{ vars.filename }}
// Output: "/data/readings_2024.csv"
\`\`\`
:::
### 🎨 Formatting with Symbols
\`\`\`js
// Arrow Indicators
A: "Temperature "
B: "↑ Rising"
// Output: "Temperature ↑ Rising"
// Status Icons
A: "🔋 "
B: {{ vars.battery_status }}
// Output: "🔋 Charging" or "🔋 Full"
\`\`\`
## Advanced Patterns
::: details Chaining Multiple Join Blocks
Sometimes you need to combine more than two pieces of text. Use multiple Join Text blocks:
**Three-Part Message:**
\`\`\`js
// First Join
A: "Hello, "
B: {{ user.name }}
// Result: "Hello, John"
// Second Join (using first result)
A: [First Join Output]
B: "! Welcome back."
// Final Output: "Hello, John! Welcome back."
\`\`\`
:::
::: details Building Complex Messages
**Multi-Line Status Report (using \\n for line breaks):**
\`\`\`js
// First Join
A: "System Status\\n"
B: "Temperature: OK\\n"
// Second Join
A: [First Join]
B: "Humidity: OK\\n"
// Third Join
A: [Second Join]
B: "Battery: Low"
/* Output:
System Status
Temperature: OK
Humidity: OK
Battery: Low
*/
\`\`\`
:::
::: details Conditional Text Building
Combine with logic blocks to build different messages:
\`\`\`js
A: "Sensor "
B: [If temperature > 80 then "⚠️ HOT" else "✓ Normal"]
// Output: "Sensor ⚠️ HOT" or "Sensor ✓ Normal"
\`\`\`
:::
## Common Patterns & Tips
::: details 1. Don't Forget Spaces!
::: danger Common Mistake
Forgetting to add spaces is the #1 beginner error!
**Wrong:**
\`\`\`js
A: "Hello"
B: "World"
// Output: "HelloWorld" ❌
\`\`\`
**Right:**
\`\`\`js
A: "Hello " // Space at the end
B: "World"
// Output: "Hello World" ✅
\`\`\`
**Also Right:**
\`\`\`js
A: "Hello"
B: " World" // Space at the beginning
// Output: "Hello World" ✅
\`\`\`
:::
::: details 2. Adding Separators
::: v-pre
| Separator | Input A | Input B | Output |
|-----------|---------|---------|--------|
| **Comma** | \`{{ vars.city }}\` | \`", USA"\` | \`"Boston, USA"\` |
| **Dash** | \`{{ vars.date }}\` | \`" - Event"\` | \`"2024-01-15 - Event"\` |
| **Colon** | \`"Error"\` | \`": Connection lost"\` | \`"Error: Connection lost"\` |
:::
::: details 3. Building Lists
\`\`\`js
// Bullet Point
A: "• "
B: {{ vars.item_name }}
// Output: "• Temperature Sensor"
// Numbered
A: "1. "
B: {{ vars.first_step }}
// Output: "1. Check connections"
\`\`\`
:::
::: details 4. Combining Numbers and Text
When joining numbers with text, the number is automatically converted:
\`\`\`js
A: "Count: "
B: {{ vars.sensor_count }}
// Output: "Count: 5"
A: {{ feeds['sensor'].value }}
B: " degrees"
// Output: "72.5 degrees"
\`\`\`
:::
::: details 5. Empty String Handling
If one input is empty, you get just the other input:
\`\`\`js
A: "Hello"
B: ""
// Output: "Hello"
A: ""
B: "World"
// Output: "World"
\`\`\`
:::
## Working with Other Blocks
::: details Join + Text Template
Use Join Text to prepare strings for templates:
\`\`\`js
// Join to create feed key
A: "sensor."
B: {{ vars.location }}
// Output: "sensor.kitchen"
// Then use in template
{{ feeds['[Join Output]'].value }}
\`\`\`
:::
::: details Join + Variables
Store joined text in a variable for reuse:
\`\`\`js
// Join
A: "Alert: "
B: {{ vars.message }}
// Store result in variable: "formatted_alert"
// Use later in multiple places
\`\`\`
:::
::: details Join + Conditionals
Build different messages based on conditions:
\`\`\`js
A: "Status: "
B: [If block result]
// Where If block returns "Online" or "Offline"
// Output: "Status: Online" or "Status: Offline"
\`\`\`
:::
## Troubleshooting
::: details Problem: Text runs together without spaces
**Solution:** Add a space at the end of the first text or beginning of the second text:
- Change \`"Hello"\` to \`"Hello "\`
- Or change \`"World"\` to \`" World"\`
:::
::: details Problem: Getting [object Object] in output
::: v-pre
**Solution:** Make sure you're passing text/string values, not complex objects. Use the .value or .name property of feeds:
- Wrong: \`B: {{ feeds['sensor.temp'] }}\`
- Right: \`B: {{ feeds['sensor.temp'].value }}\`
:::
::: details Problem: Numbers not displaying correctly
::: v-pre
**Solution:** Numbers are automatically converted to text. For formatting, use a Text Template block first:
- Basic: \`A: "Price: $" B: {{ vars.price }}\`\`"Price: $9.99"\`
- Formatted: Use template with \`{{ vars.price | round: 2 }}\` first
:::
::: details Problem: Special characters not showing
**Solution:** Use Unicode or HTML entities:
- Degree symbol: Use \`"°"\` or \`"°"\`
- Line break: Use \`"\\n"\`
- Tab: Use \`"\\t"\`
:::
## Quick Reference
::: details Task Reference Table
| Task | Input A | Input B | Output |
|------|---------|---------|--------|
| Add label | \`"Temp: "\` | \`"72°F"\` | \`"Temp: 72°F"\` |
| Add units | \`"50"\` | \`"%"\` | \`"50%"\` |
| Add prefix | \`"Error: "\` | \`message\` | \`"Error: [message]"\` |
| Add suffix | \`filename\` | \`".txt"\` | \`"[filename].txt"\` |
| Join names | \`first_name\` | \`last_name\` | \`"[first][last]"\` (no space!) |
| With space | \`first_name + " "\` | \`last_name\` | \`"[first] [last]"\` |
| Line break | \`"Line 1\\n"\` | \`"Line 2"\` | Two lines |
| Build path | \`"/home/"\` | \`username\` | \`"/home/[username]"\` |
:::
::: details When to Use Join Text vs Text Template
**Use Join Text when:**
- Combining exactly two pieces of text
- Simple concatenation without complex formatting
- Building URLs, paths, or IDs
- Adding prefixes or suffixes
**Use Text Template when:**
- Combining more than two pieces of text
- Need advanced formatting (dates, numbers)
- Using conditional logic
- Creating multi-line messages
- Need more control over output format
:::
`,
connections: {
mode: "value",
output: "expression",
},
template: "%A + %B",
inputs: {
A: {
description: "The first string of text - this will appear first in the combined output. Can be static text like 'Hello' or dynamic data from feeds/variables. Don't forget to add a space at the end if you want separation from the second text!",
description: "The first string of text",
check: "expression",
shadow: "io_text"
},
B: {
description: "The second string of text - this will appear immediately after the first text with no automatic spacing. Can be static text or dynamic values. Add a space at the beginning if you need separation from the first text.",
description: "The last string of text",
check: "expression",
shadow: "io_text"
},
},
generators: {
json: (block, generator) => {
const
leftExp = generator.valueToCode(block, 'A', 0) || null,
rightExp = generator.valueToCode(block, 'B', 0) || null,
blockPayload = JSON.stringify({
textJoin: {
left: JSON.parse(leftExp),
right: JSON.parse(rightExp),
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
@ -394,6 +53,7 @@ export default {
A: helpers.expressionToBlock(left, { shadow: "io_text" }),
B: helpers.expressionToBlock(right, { shadow: "io_text" }),
}
return { type: 'io_text_join', inputs }
}
}

View file

@ -0,0 +1,8 @@
export default {
name: 'Air Quality',
colour: 360,
contents: [
'airQuality'
],
}

View file

@ -1,3 +1,4 @@
import AirQuality from './air_quality.js'
import Feeds from './feeds.js'
import Logic from './logic.js'
import Math from './math.js'
@ -19,5 +20,6 @@ export default [
Feeds,
Notifications,
Weather,
AirQuality,
Utility
]

View file

@ -1,35 +0,0 @@
import process from 'node:process'
import { exec, execSync, spawnSync, spawn } from 'node:child_process'
import { promisify } from 'node:util'
const execAsync = promisify(exec)
// run a clean documentation build, wait for it to complete
console.log("Building docs from scratch...")
spawnSync("node", ["export.js", "docs"], { stdio: 'inherit' })
// start the file watcher and incremental builder
console.log("Starting incremental builder and file watcher...")
const docBuilder = spawn("node", ["--watch-path=./app", "export.js", "docs-incremental"], { stdio: 'inherit' })
docBuilder.on('error', err => console.log('Builder Error:', err))
docBuilder.on('exit', code => console.log('Builder Exited', code === 0 ? "Cleanly" : `With Error Code ${code}`))
// start the Vitepress docs dev server
console.log("Starting Vitepress docs server...")
const docServer = spawn("npm", ["run", "docs:dev"], { stdio: 'inherit' })
docServer.on('error', err => console.log('Server Error:', err))
docServer.on('exit', code => console.log('Server Exited', code === 0 ? "Cleanly" : `With Error Code ${code}`))
const killAll = () => {
console.log('Shutting down...')
docBuilder.kill()
docServer.kill()
process.exit()
}
// if either one exits, kill the other
console.log("Watching files for changes and servers for crashes")
docServer.on('exit', killAll)
docBuilder.on('exit', killAll)

View file

@ -6,10 +6,6 @@ const REPO = 'https://github.com/adafruit/io-actions'
// https://vitepress.dev/reference/site-config
export default defineConfig({
vite: {
clearScreen: false
},
title: "IO Actions: Block Reference",
description: "Documentation for Adafruit IO's block-based Actions",
@ -67,6 +63,10 @@ export default defineConfig({
"text": "Weather Locations",
"link": "#"
},
{
"text": "Air Quality Locations",
"link": "#"
},
{
"text": "IO Bytecode Explorer",
"link": "#"

View file

@ -1,44 +0,0 @@
# Markdown Tester
## Works
<span v-pre>
{{ span v-pre }}
::: warning
what a warning!
:::
</span>
```
{{ triple_backticks }}
```
```js
{{ js_triple_backticks }}
```
::: details Embedded in a panel
::: details multiple panels
::: details multiple panels
::: details multiple panels
a message!
:::
## Doesn't Work
<span v-pre>
`{{ single_backticks }}`
</span>
## Experiment
::: v-pre
| Separator |
|-----------|
| <span v-pre>{{ in_span_table }}</span> |
| {{ in_span_table }} |
:::

View file

@ -1,5 +1,5 @@
import { spawn, spawnSync } from 'node:child_process'
import { copyFileSync, cpSync, existsSync } from 'node:fs'
import { copyFileSync, cpSync } from 'node:fs'
import { cleanDir, write, totalBytesWritten } from "./export_util.js"
import DefinitionSet from '#src/definitions/definition_set.js'
@ -20,7 +20,6 @@ const
definitions = await DefinitionSet.load(),
exporters = {
// Build the Blockly application itself
"app": async (destination="export") => {
// clear the export directory
@ -35,13 +34,13 @@ const
})
},
// Build the documentation for the Blockly application
"docs": async () => {
// TODO: check and warn if block images haven't been generated
if(!existsSync("docs/block_images/action_root.png")) {
console.log("Block images missing from docs/block_images!")
console.log("Run: `npm run export:block-images` before exporting the docs")
process.exit(1)
// allow option to skip image generation
const skipImages = taskArgs.includes("skipImages")
if(!skipImages) {
await exporters.blockImages()
cleanDir("docs/block_images")
cpSync("tmp/block_images/images", "docs/block_images", { recursive: true })
}
await exporters.app("docs/blockly")
@ -55,50 +54,31 @@ const
})
},
// Build the documentation but only touch files that have changed
// This is good to pair with a process that watches files for changes
"docs-incremental": async () => {
await exportTo("docs", definitions, exportItem => {
exportItem.blockIndex("blocks/index.md")
exportItem.blockPages()
exportItem.sidebar("blocks/_blocks_sidebar.json")
})
},
// Create png images of all blocks by:
// - creating a temporary app with no toolbox and all blocks on the workspace
// - serving that application
// - driving a browser to it with Cypress
// - right clicking on each block and clicking "download image"
"blockImages": async (imageDestination="docs/block_images") => {
const tmpAppDestination = "tmp/block_images_app"
cleanDir(tmpAppDestination)
"blockImages": async () => {
const destination = "tmp/block_images"
cleanDir(destination)
cleanDir(`${destination}/images`)
// export a special app with no toolbox, all blocks on workspace
await exportTo(tmpAppDestination, definitions, exportItem => {
await exportTo(destination, definitions, exportItem => {
exportItem.workspaceAllBlocks("workspace.json")
write(`${tmpAppDestination}/toolbox.json`, "null")
write(`${destination}/toolbox.json`, "null")
exportItem.blocks("blocks.json")
exportItem.script("blockly_app.js")
// TODO: make a DocumentExporter for generating html wrappers
copyFileSync("src/exporters/document_templates/blockly_workspace.template.html", `${tmpAppDestination}/index.html`)
copyFileSync("src/exporters/document_templates/blockly_workspace.template.html", `${destination}/index.html`)
})
// serve the screenshot app
// serve it
console.log('Serving workspace for screenshots...')
const viteProcess = spawn("npx", ["vite", "serve", tmpAppDestination])
// prepare the image location
cleanDir(imageDestination)
const viteProcess = spawn("npx", ["vite", "serve", destination])
// extract the screenshots
console.log('Generating screenshots...')
await spawnSync("npx", ["cypress", "run",
"--config", `downloadsFolder=${imageDestination}`,
spawnSync("npx", ["cypress", "run",
"--config", `downloadsFolder=${destination}/images`,
"--config-file", `cypress/cypress.config.js`,
"--browser", "chromium",
"--spec", "cypress/e2e/block_images.cy.js",
], { stdio: 'inherit' })
])
console.log('Generation complete.')
// kill the server
@ -106,19 +86,16 @@ const
console.log("Vite failed to exit gracefully")
process.exit(1)
}
console.log('Server closed.')
}
},
exporterNames = Object.keys(exporters)
// Look up the requested exporter
if(!exporterNames.includes(toExport)) {
console.error(`Export Error: No exporter found for: "${toExport}"\nValid exporters: "${exporterNames.join('", "')}"`)
process.exit(1)
}
// Execute the export
const startTime = Date.now()
console.log(`\nStarting Export: ${toExport}`)
console.log("=======================")
@ -128,8 +105,7 @@ await exporter()
const elapsed = Date.now() - startTime
console.log("=======================")
console.log(`🏁 Done (${elapsed}ms) 🏁`)
console.log(`🏁 Done. Wrote ${totalBytesWritten.toFixed(3)}k in ${elapsed}ms 🏁`)
// Bye!
process.exit(0)

View file

@ -11,18 +11,17 @@
},
"main": "index.js",
"scripts": {
"start": "node dev_server.js",
"start": "vite --force",
"test": "node --test",
"test-watch": "node --test --watch",
"test-snapshots": "node --test test/app/blocks/snapshots/block_snapshots_test.js",
"test-update-snapshots": "node --test --test-update-snapshots test/app/blocks/snapshots/block_snapshots_test.js",
"lint": "eslint src/",
"lint-export": "eslint export/",
"export:app": "node export.js app",
"export:block-images": "node export.js blockImages",
"build": "npm run export:app && vite build",
"build-all-branches": "node build_all_branches.js",
"preview": "npm run build && vite preview",
"docs:export": "node export.js docs",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",

View file

@ -194,8 +194,7 @@ BlockDefinition.parseRawDefinition = function(rawBlockDefinition, definitionPath
// take the first line of the description
// blockDef.tooltip = blockDef.description.split("\n")[0]
// take the first sentence of the description
blockDef.tooltip = blockDef.description.split(/\.(\s|$)/)[0]
if(!blockDef.tooltip.endsWith("?")) { blockDef.tooltip += "." }
blockDef.tooltip = blockDef.description.split(/\.(\s|$)/)[0] + "."
blockDef.disabled = !!rawBlockDefinition.disabled
blockDef.connections = rawBlockDefinition.connections
blockDef.template = rawBlockDefinition.template

View file

@ -1,6 +1,7 @@
import { mkdirSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import { forEach, identity, pickBy } from 'lodash-es'
import { writeFileIfDifferent } from '#src/util.js'
import toBlockMarkdown from "#src/docs/render_block.js"
@ -24,10 +25,10 @@ export default class BlockPageExporter {
forEach(this.definitionSet.blocks, blockDefinition => {
const
docPath = options.filenameFunc(blockDefinition),
fullPath = `${this.destination}/${docPath}`,
newContent = toBlockMarkdown(blockDefinition)
fullPath = `${this.destination}/${docPath}`
writeFileIfDifferent(fullPath, newContent)
mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, toBlockMarkdown(blockDefinition))
})
}
@ -40,4 +41,3 @@ export default class BlockPageExporter {
this.export(exportOptions)
}
}

View file

@ -1,6 +1,5 @@
import { find, forEach, isString, map, sortBy } from 'lodash-es'
import { writeFileIfDifferent } from '#src/util.js'
import { writeFileSync } from 'fs'
import { find, forEach, isString, map } from 'lodash-es'
export default class SidebarExporter {
@ -35,7 +34,7 @@ export default class SidebarExporter {
blockSidebar.items.push(uncategorizedCategory)
forEach(sortBy(this.definitionSet.blocks, 'type'), blockDefinition => {
forEach(this.definitionSet.blocks, blockDefinition => {
const
sidebarEntry = {
text: blockDefinition.name,
@ -70,7 +69,7 @@ export default class SidebarExporter {
? options.toFile
: `_blocks_sidebar.json`
writeFileIfDifferent(`${this.destination}/${filename}`, JSON.stringify(blockSidebar, null, 2))
writeFileSync(`${this.destination}/${filename}`, JSON.stringify(blockSidebar, null, 2))
}
exportToFile = (toFile=true) => {

View file

@ -74,12 +74,24 @@ const workspace = inject('blocklyDiv', {
[ "Varick", "2" ],
[ "Shenzhen", "3" ],
],
airQualityLocationOptions: [
[ "Industry City", "1" ],
[ "Varick", "2" ],
[ "Shenzhen", "3" ],
],
currentWeatherByLocation: {
1: {
current: {
cloudCover: "5.4321",
}
}
},
currentAirQualityByLocation: {
1: {
current: {
european_aqi: "25",
}
}
}
},
injectOptions: {
@ -180,6 +192,41 @@ workspace.addChangeListener(function({ blockId, type, name, element, newValue, o
}, 1500)
})
// air quality block live data fetcher/updater
workspace.addChangeListener(function({ blockId, type, name, element, newValue, oldValue }) {
// when an air quality block changes its location
if(!blockId || type !== "change" || workspace.getBlockById(blockId).type !== "airQuality" || element !== "field" || name === "AIR_QUALITY_PROPERTY_HELP") {
return
}
// quick/dirty for demo
// if it is changing now, use newValue, otherwise fetch from field
const
block = workspace.getBlockById(blockId),
currentLocation = name === "POWER_UP_ID"
? newValue
: block.getFieldValue('POWER_UP_ID'),
currentTimeKey = name === "AIR_QUALITY_TIME"
? newValue
: block.getFieldValue('AIR_QUALITY_TIME'),
currentMetricKey = name === "AIR_QUALITY_PROPERTY"
? newValue
: block.getFieldValue('AIR_QUALITY_PROPERTY') // this can be wrong if time changed and props haven't been replaced yet
const newData = {
[currentLocation]: {
[currentTimeKey]: {
[currentMetricKey]: Math.random().toString().slice(0,5)
}
}
}
// delay to simulate a request happening
setTimeout(() => {
addExtensionData("currentAirQualityByLocation", newData)
}, 1500)
})
setInterval(() => {
const

View file

@ -1,21 +1,6 @@
import { dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { createHash } from 'crypto'
import { map } from 'lodash-es'
const getStringHash = stringToHash => {
const hash = createHash('sha256')
hash.update(stringToHash)
return hash.digest('hex')
}
const getFileHash = filePath => {
if(!existsSync(filePath)) { return '' }
return getStringHash(readFileSync(filePath))
}
export const
niceTemplate = tplString => {
const
@ -34,16 +19,4 @@ export const
// TODO: support niceties for markdown, double-newlines, escaping, etc
return tplString
},
writeFileIfDifferent = (filename, content) => {
const
fileHash = getFileHash(filename),
contentHash = getStringHash(content)
if(fileHash !== contentHash) {
console.log("writing", filename)
mkdirSync(dirname(filename), { recursive: true })
writeFileSync(filename, content)
}
}

View file

@ -38,6 +38,7 @@ describe("DefinitionSet", function() {
it("has mixins, including inline mixins from blocks", function() {
assert.isAbove(Object.keys(this.definitionSet.mixins).length, 1)
assert.exists(this.definitionSet.mixins.weatherMixin, "Expected an inline mixin to be present")
assert.exists(this.definitionSet.mixins.airQualityMixin, "Expected air quality inline mixin to be present")
})
it("has extensions, including inline extensions from blocks", function() {