npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

smartplant

v3.0.5

Published

A bridge between AI and plants: pluggable sensors, persistent plant memory, multi-provider AI and a first-person plant voice.

Readme

smartplant

HEADER

A bridge between AI and plants. Pluggable sensors, persistent plant memory, multi-provider AI, and a first-person plant voice.

Node.js 18+. Zero runtime dependencies in the core.

Its habit, everywhere: it refuses rather than guesses. A layer that lacks what it needs says so and names the sensor that would unlock it, instead of substituting a plausible number. That is why a plant with four sensors says little and means all of it.

npm install smartplant

Quick start

import { createPlant } from 'smartplant'

const plant = await createPlant( {
  name    : 'Rosa',
  species : 'Monstera deliciosa',
  sensor  : 'mock',
  ai      : { provider : 'mock' },
} )

await plant.read()
console.log( plant.status() )

That runs with no hardware, no API key and no network.

await plant.systemDiagnosis()   // 25 checks: what is wired, what is not, what to fix
await plant.serve()             // the whole thing in a browser, read-only

Beyond the basics

Every layer works alone and they compose. The full write-up is in the main README; this is what exists and what each is for.

| | | | --- | --- | | Electrophysiology | Action and variation potentials, circadian rhythm, an electrome fingerprint that is this plant's rather than the species' | | Internal states | Five estimates of what the plant is doing — defence activation, internal water stress, accumulated load, stress memory, circadian integrity. Low confidence removes a state's authority rather than annotating it | | Spectral | LED as a probe rather than illumination, with dose ledgers and interlocks | | Vision | Classical phenotyping, ONNX models, a PlantCV bridge | | Colony | Plants talking to plants over a channel closed to people, with delivery that survives a link failing | | Space | WiFi presence and a lidar scan — is anybody in the room, and how far away is the neighbour really | | Body | Multirate fusion, reflexes, safety limits, personalization | | Navigation | The constraints a robot stack cannot know, over a planner it delegates to | | Dashboard | plant.serve() — a dark terminal on localhost, read-only | | Co-adaptation | One door every action goes through, and a record of when its own refusals turned out to have been unnecessary | | Watering | Volume from the pot, fraction from the archetype, duration from a measured pump — and a check that the water arrived | | Thermal | Leaf temperature for every pixel of the canopy, without publishing an absolute it cannot support | | The pot | Whether the container is running out, and a transplant mode where you say the litres and nothing else |

Reading the space

await plant.attachSensor( {
  driver : 'presence',           // WiFi. `sensing` has no default:
  sensing: 'csi',                // RSSI is one number, CSI is one per subcarrier,
  sample : readRadio,            // and what can honestly be reported differs
} )

await plant.attachSensor( { driver: 'lidar', scan: readRplidar } )

Presence answers two things the library was short of: whether the room is occupied, which is what the UV-B interlock needs, and whether anybody moved at the moment of an electrical event — brushing a leaf produces an action potential that looks exactly like the start of a wound response.

One lidar scan measures the distance between two plants, which every shared-humidity and CO₂ conclusion had been resting on and which a person had been typing in. Mapping is delegated: Nav2 does it properly.

Not claimed: pose, identity, breathing, counting people, or a map of your flat.

How much water

const plant = await createPlant( {
  species : 'Monstera deliciosa',
  pot     : { diameterCm: 22 },     // the number nothing can work out for itself
  pump    : { mlPerSecond: 25 },    // measured with a jug, never looked up
} )

plant.wateringPlan()   // { ml: 640, seconds: 25.6, litres: 5.8 }

The archetype decides what fraction of the substrate to wet; the pot decides the volume. The same plant in a 12 cm pot and a 40 cm one wants thirty times the water, so a table of millilitres per species is wrong by an order of magnitude for one of them — and no pot size is assumed, because a default would carry that same factor.

Watering by pump is open-loop: a blocked line, an empty tank and a tube that has fallen out of the pot are identical from the relay, and each records a watering that did not happen. confirmWatering() is what tells you the water arrived.

The pot is part of the body

plant.rootSpace()          // is this container running out?
await plant.transplant( { volumeL: 5 } )   // the only thing you tell it

It does not see the roots. What it sees is whether the plant is behaving like one whose pot is filling up — drying faster than it did, wanting water sooner in the same container — which shows months before a person notices, because a person notices when the plant is already suffering. It recommends and never acts.

A transplant rescales the doses, closes the old soil baseline, suspends the root-space estimate and holds off elective things until the readings settle. The calibration record, the ledger and the trajectory all survive: the same plant was moved, not replaced.

Being wrong about itself

plant.mayI( 'probe' )          // { allowed: false, blockedBy: [ … ] }
plant.posture()                // every action, and what the states make of it
plant.trajectory()             // is the coupling deepening or degrading
await plant.exportIdentity()   // the same plant, into its next body

Every action goes through one door, and care is never blocked. An internal state that refuses things is making a claim nothing had ever checked, and there is exactly one place the other arm of that experiment exists: an override is a natural experiment. Somebody who passes { force: true } has run the trial the system declined to run, and if nothing went wrong, that is a false positive observed rather than inferred.

A colony that does not go quiet

plant.colony.addLink( backup, { name: 'backup', priority: 20 } )
plant.colony.deliveryHealth()

A message goes out over every link worth trying and is spooled if none took it. Messages have a shelf life, because "I am thirsty" delivered two hours late waters a plant that was watered ninety minutes ago; what survives the wait arrives flagged as late. Design taken from Maskpert.

Configuration

createPlant( {
  name     : 'Rosa',                  // plant name
  species  : 'Monstera deliciosa',    // improves AI advice
  type     : 'indoor',                // 'indoor' | 'outdoor'
  language : 'es',                    // en es fr de it pt nl ru zh ja
  persona  : 'plant',                 // plant | botanist | poet | scientist | child
  interval : 60_000,                  // monitoring interval (ms)

  sensor : 'mock',                    // or { driver: 'serial', path: '/dev/ttyACM0' }

  ai : {
    provider    : 'ollama',           // gemini openai claude grok ollama mock
    apiKey      : undefined,          // falls back to the provider's env var
    model       : undefined,
    retries     : 2,
    cacheTTL    : 30_000,             // dedupes identical requests
    temperature : 0.7,
  },

  memory : {
    path        : './rosa.json',      // omit for in-memory only
    maxReadings : 2000,
    autosave    : true,
  },

  ranges : {                          // comfort bands (AI-generated if omitted)
    soil : { min : 35, max : 70 },
  },
} )

API

Sensing

| Method | Returns | Notes | | --- | --- | --- | | read() | Reading | Reads every attached sensor, stores it, emits events | | attachSensor(spec) | SensorDriver | Id, {driver, ...config}, or an instance | | registerSensor(id, driver) | this | Register a custom driver class or instance | | getSensor(id?) | SensorDriver | Defaults to the first attached driver | | status(reading?) | string | One-line emoji status. No AI, no network | | happiness(reading?) | number | Wellbeing 0-100 | | context(reading?) | object | Full situation: identity, reading, ranges, trends, care log |

AI

| Method | Returns | Notes | | --- | --- | --- | | analyze(question, opts?) | object | Structured answer with your schema keys guaranteed | | speak(message?, opts?) | string | Free-form reply in the plant's voice | | learnSpecies(opts?) | object | Generates and stores the species care profile. Run once |

Both degrade gracefully: if the AI is unreachable or unconfigured, they return the deterministic offline voice rather than throwing.

const result = await plant.analyze( 'Does this plant need repotting?', {
  schema : { advice : '', urgency : 'low', potSizeCm : 0 },
} )
// result.advice, result.urgency, result.potSizeCm are always present

Memory & care

| Method | Notes | | --- | --- | | water(detail?) | Records a watering and takes a reading | | fertilize(detail?) | Records a feeding | | log(type, detail?) | Any other event: repotting, pruning, a spotted pest | | note(text) | Free-text observation | | memory.stats(hours) | Per-metric {min, max, avg, trend, n} | | memory.daysSince(type) | Whole days since an event, or null | | memory.forget() | Erase stored history |

Lifecycle

| Method | Notes | | --- | --- | | init() | Loads memory and attaches the sensor. Idempotent | | startMonitoring(opts?) | Reads immediately, then on an interval | | stopMonitoring() | Stops the loop | | destroy() | Stops everything, disconnects transports, saves memory |

Events

import { EVENTS } from 'smartplant'

plant.on( EVENTS.THIRSTY, e => console.log( e.metric, e.value, e.range ) )
plant.on( '*', ( payload, name ) => console.log( name ) )

sensor:reading · sensor:error · plant:thirsty · plant:drowning · plant:too-hot · plant:too-cold · plant:too-dark · plant:too-bright · plant:stressed · plant:happy · plant:spoke · alert · plugin:loaded · ai:request · ai:response · error

Listeners may be async — emit awaits them. One listener throwing never stops the others or the monitoring loop.

Custom sensors

import { SensorDriver } from 'smartplant'

class MyProbe extends SensorDriver {
  static id = 'my-probe'

  async connect() { /* open transport */ this.connected = true; return this }

  async read() {
    // normalize() drops non-numeric values and stamps the timestamp
    return this.normalize( { soil : 42, temperature : 21.5 } )
  }
}

plant.registerSensor( 'my-probe', MyProbe )
await plant.attachSensor( 'my-probe' )

Recognized metrics: temperature (°C), humidity (%), soil (%), light (lux), ph, conductivity (µS/cm).

Custom AI providers

plant.ai.registerProvider( 'my-llm', {
  label    : 'My LLM',
  needsKey : false,
  generate : async ( { prompt, system } ) => callMyModel( system, prompt ),
} )

plant.ai.use( 'my-llm' )

Plugins

import { definePlugin } from 'smartplant'

export default definePlugin( {
  name    : 'repotting',
  persona : 'botanist',
  schema  : { advice : '', urgency : 'low' },

  on : {
    async 'plant:stressed'( event ) {
      console.log( await this.whenToRepot() )
    },
  },

  methods : {
    async whenToRepot( input = {} ) {
      return this.ask( 'Is this plant root-bound and ready for a bigger pot?', { extra : input } )
    },
  },
} )

Inside a plugin, this.plant is the kernel, this.ask() runs a context-injected structured AI call, and this.context() is the current situation. Event listeners are detached automatically on destroy().

Subpath exports

import { PlantMemory }   from 'smartplant/memory'
import { SerialSensor }  from 'smartplant/sensors/serial'
import { MqttSensor }    from 'smartplant/sensors/mqtt'
import { statusLine }    from 'smartplant/voice'
import { definePlugin }  from 'smartplant/plugin'
import { internalStates } from 'smartplant/states'
import { planMove }       from 'smartplant/navigation'
import { serveVitals }    from 'smartplant/dashboard'
import { readSpace }      from 'smartplant/spatial'
import { PresenceSensor } from 'smartplant/sensors/presence'
import { LidarSensor }    from 'smartplant/sensors/lidar'
import { ThermalSensor }  from 'smartplant/sensors/thermal'
import { dose }           from 'smartplant/archetypes'
import { couplingState } from 'smartplant/colony'

Migrating from 1.x

1.x exported a single SmartPlant class from src/main.js that started an interactive CLI on import, so it could not be used as a library. The plugins called smartplant.getSensor(), smartplant.analyze() and smartplant.registerPlugin(), none of which existed.

  • The CLI now lives in src/cli.js; importing the package no longer starts it.
  • getSensor(), analyze() and registerPlugin() exist and are tested.
  • import { SmartPlant } from 'smartplant/src/main.js' still resolves.
  • Stored historicalData from 1.x is migrated automatically on first load.
  • Plugins are ESM and take the kernel: await plant.use( plugin ).

What has and has not touched hardware

Worth knowing before trusting any of it with a plant you care about.

Exercised against something real. Serial, over a pseudo-terminal the serialport package cannot distinguish from a port with an Arduino on it — JSON frames, CSV frames, rubbish on the line, and a device that stops talking going stale rather than serving its last value forever. MQTT, against a real broker over a real socket using the same mqtt package you would install, including a retained message published before subscribing and wildcard topics. The electrode over that same real serial transport rather than its simulator.

Never touched a device. Everything else. No ESP32-S3 with CSI firmware has fed the presence driver, no RPLIDAR has fed the lidar one, no real Home Assistant has answered the integration — that was a stand-in server speaking its API, which proves the library speaks the language and not that Home Assistant accepts it. The seven AI providers are exercised through their timeout paths and never really called.

The physiology rests on published work rather than on validation with plants. That low red:far-red suppresses jasmonate signalling is well established; that this code produces that response in a living plant is not something anybody has checked.

Testing

npm test

The mock sensor and mock AI provider are deterministic, so the whole library is testable offline.

📜 License

MIT — see LICENSE.