Compare commits
3 commits
main
...
air-qualit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d42842851 | ||
|
|
9a3075da9c | ||
|
|
3b9df81a56 |
18 changed files with 514 additions and 534 deletions
3
.github/workflows/docs.yml
vendored
3
.github/workflows/docs.yml
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
126
app/blocks/power_ups/air_quality.js
Normal file
126
app/blocks/power_ups/air_quality.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
281
app/blocks/power_ups/air_quality_mixin.js
Normal file
281
app/blocks/power_ups/air_quality_mixin.js
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
app/toolbox/air_quality.js
Normal file
8
app/toolbox/air_quality.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default {
|
||||||
|
name: 'Air Quality',
|
||||||
|
colour: 360,
|
||||||
|
|
||||||
|
contents: [
|
||||||
|
'airQuality'
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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": "#"
|
||||||
|
|
|
||||||
44
docs/test.md
44
docs/test.md
|
|
@ -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 }} |
|
|
||||||
:::
|
|
||||||
|
|
||||||
64
export.js
64
export.js
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
47
src/index.js
47
src/index.js
|
|
@ -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
|
||||||
|
|
|
||||||
27
src/util.js
27
src/util.js
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue