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 - name: Install dependencies
run: npm ci run: npm ci
- name: Export Block Images
run: npm run export:block-images
- name: Export Block Definitions to Markdown - name: Export Block Definitions to Markdown
run: npm run docs:export 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 git clone https://github.com/adafruit/io-actions
cd io-actions cd io-actions
npm i npm i
npm run export:block-images # run once, and whenever images need updating
npm start 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 ### Exporting
Export a Blockly application: 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", name: "Join Text",
colour: 180, colour: 180,
inputsInline: true, inputsInline: true,
description: ` description: "Join two pieces of text into one.",
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
\`\`\`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: { connections: {
mode: "value", mode: "value",
output: "expression", output: "expression",
}, },
template: "%A + %B", template: "%A + %B",
inputs: { inputs: {
A: { 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", check: "expression",
shadow: "io_text" shadow: "io_text"
}, },
B: { 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", check: "expression",
shadow: "io_text" shadow: "io_text"
}, },
}, },
generators: { generators: {
json: (block, generator) => { json: (block, generator) => {
const const
leftExp = generator.valueToCode(block, 'A', 0) || null, leftExp = generator.valueToCode(block, 'A', 0) || null,
rightExp = generator.valueToCode(block, 'B', 0) || null, rightExp = generator.valueToCode(block, 'B', 0) || null,
blockPayload = JSON.stringify({ blockPayload = JSON.stringify({
textJoin: { textJoin: {
left: JSON.parse(leftExp), left: JSON.parse(leftExp),
right: JSON.parse(rightExp), right: JSON.parse(rightExp),
}, },
}) })
return [ blockPayload, 0 ] return [ blockPayload, 0 ]
} }
}, },
regenerators: { regenerators: {
json: (blockObject, helpers) => { json: (blockObject, helpers) => {
const const
@ -394,6 +53,7 @@ export default {
A: helpers.expressionToBlock(left, { shadow: "io_text" }), A: helpers.expressionToBlock(left, { shadow: "io_text" }),
B: helpers.expressionToBlock(right, { shadow: "io_text" }), B: helpers.expressionToBlock(right, { shadow: "io_text" }),
} }
return { type: 'io_text_join', inputs } 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 Feeds from './feeds.js'
import Logic from './logic.js' import Logic from './logic.js'
import Math from './math.js' import Math from './math.js'
@ -19,5 +20,6 @@ export default [
Feeds, Feeds,
Notifications, Notifications,
Weather, Weather,
AirQuality,
Utility 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 // https://vitepress.dev/reference/site-config
export default defineConfig({ export default defineConfig({
vite: {
clearScreen: false
},
title: "IO Actions: Block Reference", title: "IO Actions: Block Reference",
description: "Documentation for Adafruit IO's block-based Actions", description: "Documentation for Adafruit IO's block-based Actions",
@ -67,6 +63,10 @@ export default defineConfig({
"text": "Weather Locations", "text": "Weather Locations",
"link": "#" "link": "#"
}, },
{
"text": "Air Quality Locations",
"link": "#"
},
{ {
"text": "IO Bytecode Explorer", "text": "IO Bytecode Explorer",
"link": "#" "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 { 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 { cleanDir, write, totalBytesWritten } from "./export_util.js"
import DefinitionSet from '#src/definitions/definition_set.js' import DefinitionSet from '#src/definitions/definition_set.js'
@ -20,7 +20,6 @@ const
definitions = await DefinitionSet.load(), definitions = await DefinitionSet.load(),
exporters = { exporters = {
// Build the Blockly application itself
"app": async (destination="export") => { "app": async (destination="export") => {
// clear the export directory // clear the export directory
@ -35,13 +34,13 @@ const
}) })
}, },
// Build the documentation for the Blockly application
"docs": async () => { "docs": async () => {
// TODO: check and warn if block images haven't been generated // allow option to skip image generation
if(!existsSync("docs/block_images/action_root.png")) { const skipImages = taskArgs.includes("skipImages")
console.log("Block images missing from docs/block_images!") if(!skipImages) {
console.log("Run: `npm run export:block-images` before exporting the docs") await exporters.blockImages()
process.exit(1) cleanDir("docs/block_images")
cpSync("tmp/block_images/images", "docs/block_images", { recursive: true })
} }
await exporters.app("docs/blockly") await exporters.app("docs/blockly")
@ -55,50 +54,31 @@ const
}) })
}, },
// Build the documentation but only touch files that have changed "blockImages": async () => {
// This is good to pair with a process that watches files for changes const destination = "tmp/block_images"
"docs-incremental": async () => { cleanDir(destination)
await exportTo("docs", definitions, exportItem => { cleanDir(`${destination}/images`)
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)
// export a special app with no toolbox, all blocks on workspace // 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") exportItem.workspaceAllBlocks("workspace.json")
write(`${tmpAppDestination}/toolbox.json`, "null") write(`${destination}/toolbox.json`, "null")
exportItem.blocks("blocks.json") exportItem.blocks("blocks.json")
exportItem.script("blockly_app.js") exportItem.script("blockly_app.js")
// TODO: make a DocumentExporter for generating html wrappers // 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...') console.log('Serving workspace for screenshots...')
const viteProcess = spawn("npx", ["vite", "serve", tmpAppDestination]) const viteProcess = spawn("npx", ["vite", "serve", destination])
// prepare the image location
cleanDir(imageDestination)
// extract the screenshots // extract the screenshots
console.log('Generating screenshots...') console.log('Generating screenshots...')
await spawnSync("npx", ["cypress", "run", spawnSync("npx", ["cypress", "run",
"--config", `downloadsFolder=${imageDestination}`, "--config", `downloadsFolder=${destination}/images`,
"--config-file", `cypress/cypress.config.js`, "--config-file", `cypress/cypress.config.js`,
"--browser", "chromium", ])
"--spec", "cypress/e2e/block_images.cy.js",
], { stdio: 'inherit' })
console.log('Generation complete.') console.log('Generation complete.')
// kill the server // kill the server
@ -106,19 +86,16 @@ const
console.log("Vite failed to exit gracefully") console.log("Vite failed to exit gracefully")
process.exit(1) process.exit(1)
} }
console.log('Server closed.') console.log('Server closed.')
} }
}, },
exporterNames = Object.keys(exporters) exporterNames = Object.keys(exporters)
// Look up the requested exporter
if(!exporterNames.includes(toExport)) { if(!exporterNames.includes(toExport)) {
console.error(`Export Error: No exporter found for: "${toExport}"\nValid exporters: "${exporterNames.join('", "')}"`) console.error(`Export Error: No exporter found for: "${toExport}"\nValid exporters: "${exporterNames.join('", "')}"`)
process.exit(1) process.exit(1)
} }
// Execute the export
const startTime = Date.now() const startTime = Date.now()
console.log(`\nStarting Export: ${toExport}`) console.log(`\nStarting Export: ${toExport}`)
console.log("=======================") console.log("=======================")
@ -128,8 +105,7 @@ await exporter()
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
console.log("=======================") console.log("=======================")
console.log(`🏁 Done (${elapsed}ms) 🏁`) console.log(`🏁 Done. Wrote ${totalBytesWritten.toFixed(3)}k in ${elapsed}ms 🏁`)
// Bye!
process.exit(0) process.exit(0)

View file

@ -11,18 +11,17 @@
}, },
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node dev_server.js", "start": "vite --force",
"test": "node --test", "test": "node --test",
"test-watch": "node --test --watch", "test-watch": "node --test --watch",
"test-snapshots": "node --test test/app/blocks/snapshots/block_snapshots_test.js", "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", "test-update-snapshots": "node --test --test-update-snapshots test/app/blocks/snapshots/block_snapshots_test.js",
"lint": "eslint src/", "lint": "eslint src/",
"lint-export": "eslint export/", "lint-export": "eslint export/",
"export:app": "node export.js app", "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:export": "node export.js docs",
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",

View file

@ -194,8 +194,7 @@ BlockDefinition.parseRawDefinition = function(rawBlockDefinition, definitionPath
// take the first line of the description // take the first line of the description
// blockDef.tooltip = blockDef.description.split("\n")[0] // blockDef.tooltip = blockDef.description.split("\n")[0]
// take the first sentence of the description // take the first sentence of the description
blockDef.tooltip = blockDef.description.split(/\.(\s|$)/)[0] blockDef.tooltip = blockDef.description.split(/\.(\s|$)/)[0] + "."
if(!blockDef.tooltip.endsWith("?")) { blockDef.tooltip += "." }
blockDef.disabled = !!rawBlockDefinition.disabled blockDef.disabled = !!rawBlockDefinition.disabled
blockDef.connections = rawBlockDefinition.connections blockDef.connections = rawBlockDefinition.connections
blockDef.template = rawBlockDefinition.template 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 { forEach, identity, pickBy } from 'lodash-es'
import { writeFileIfDifferent } from '#src/util.js'
import toBlockMarkdown from "#src/docs/render_block.js" import toBlockMarkdown from "#src/docs/render_block.js"
@ -24,10 +25,10 @@ export default class BlockPageExporter {
forEach(this.definitionSet.blocks, blockDefinition => { forEach(this.definitionSet.blocks, blockDefinition => {
const const
docPath = options.filenameFunc(blockDefinition), docPath = options.filenameFunc(blockDefinition),
fullPath = `${this.destination}/${docPath}`, fullPath = `${this.destination}/${docPath}`
newContent = toBlockMarkdown(blockDefinition)
writeFileIfDifferent(fullPath, newContent) mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, toBlockMarkdown(blockDefinition))
}) })
} }
@ -40,4 +41,3 @@ export default class BlockPageExporter {
this.export(exportOptions) this.export(exportOptions)
} }
} }

View file

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

View file

@ -74,12 +74,24 @@ const workspace = inject('blocklyDiv', {
[ "Varick", "2" ], [ "Varick", "2" ],
[ "Shenzhen", "3" ], [ "Shenzhen", "3" ],
], ],
airQualityLocationOptions: [
[ "Industry City", "1" ],
[ "Varick", "2" ],
[ "Shenzhen", "3" ],
],
currentWeatherByLocation: { currentWeatherByLocation: {
1: { 1: {
current: { current: {
cloudCover: "5.4321", cloudCover: "5.4321",
} }
} }
},
currentAirQualityByLocation: {
1: {
current: {
european_aqi: "25",
}
}
} }
}, },
injectOptions: { injectOptions: {
@ -180,6 +192,41 @@ workspace.addChangeListener(function({ blockId, type, name, element, newValue, o
}, 1500) }, 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(() => { setInterval(() => {
const 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' 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 export const
niceTemplate = tplString => { niceTemplate = tplString => {
const const
@ -34,16 +19,4 @@ export const
// TODO: support niceties for markdown, double-newlines, escaping, etc // TODO: support niceties for markdown, double-newlines, escaping, etc
return tplString 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() { it("has mixins, including inline mixins from blocks", function() {
assert.isAbove(Object.keys(this.definitionSet.mixins).length, 1) 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.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() { it("has extensions, including inline extensions from blocks", function() {