Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,20 @@ export default [
{
plugins: {
import: fixupPluginRules(importPlugin),
jsonc,
"no-unsanitized": noUnsanitized,
"simple-import-sort": simpleImportSort,
"sort-keys-fix": sortKeysFix,
"sort-keys-fix": fixupPluginRules(sortKeysFix),
unicorn,
prettier,
},
languageOptions: {
globals: { ...globals.node },
},
},
...jsonc.configs["flat/recommended-with-jsonc"],
...compat
.extends(
"plugin:@typescript-eslint/recommended",
"plugin:jsonc/recommended-with-jsonc",
"plugin:prettier/recommended",
"plugin:@cspell/recommended",
)
Expand Down
75 changes: 38 additions & 37 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"name": "@digital-alchemy/hass",
"repository": "https://github.com/Digital-Alchemy-TS/hass",
"homepage": "https://docs.digital-alchemy.app",
"version": "25.11.27",
"version": "26.6.13",
"description": "Typescript APIs for Home Assistant. Includes rest & websocket bindings",
"scripts": {
"build": "rm -rf dist/; tsc",
"lint": "eslint src",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "tsc --project ./tsconfig.lib.json",
"upgrade": "yarn up '@digital-alchemy/*'"
"upgrade": "ncu --upgrade && truncate -s 0 yarn.lock && yarn install"
},
"bugs": {
"email": "bugs@digital-alchemy.app",
Expand Down Expand Up @@ -58,54 +58,55 @@
},
"license": "MIT",
"devDependencies": {
"@cspell/eslint-plugin": "^9.3.2",
"@digital-alchemy/core": "^25.11.23",
"@digital-alchemy/synapse": "^25.8.21",
"@digital-alchemy/type-writer": "^25.11.16",
"@eslint/compat": "^2.0.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@faker-js/faker": "^10.1.0",
"@cspell/eslint-plugin": "^10.0.1",
"@digital-alchemy/core": "^26.5.30",
"@digital-alchemy/synapse": "^26.2.6",
"@digital-alchemy/type-writer": "^26.2.12",
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@faker-js/faker": "^10.4.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.1",
"@types/node": "^25.9.3",
"@types/node-cron": "^3.0.11",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@vitest/coverage-v8": "^4.0.13",
"@vitest/ui": "4.0.13",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"eslint": "9.39.1",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/ui": "4.1.8",
"dayjs": "^1.11.21",
"dotenv": "^17.4.2",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsonc": "^2.21.0",
"eslint-plugin-no-unsanitized": "^4.1.4",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.5",
"eslint-plugin-jsonc": "^3.2.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-security": "^4.0.1",
"eslint-plugin-simple-import-sort": "^13.0.0",
"eslint-plugin-sonarjs": "^4.0.3",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-unicorn": "^62.0.0",
"eslint-plugin-unicorn": "^65.0.1",
"node-cron": "^4.2.1",
"prettier": "^3.6.2",
"semver": "^7.7.3",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
"vitest": "^4.0.13",
"ws": "^8.18.3"
"npm-check-updates": "^22.2.3",
"prettier": "^3.8.4",
"semver": "^7.8.4",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"uuid": "^14.0.0",
"vitest": "^4.1.8",
"ws": "^8.21.0"
},
"peerDependencies": {
"@digital-alchemy/core": "*"
},
"dependencies": {
"dayjs": "^1.11.19",
"semver": "^7.7.3",
"type-fest": "^5.2.0",
"uuid": "^13.0.0",
"ws": "^8.18.3"
"dayjs": "^1.11.21",
"semver": "^7.8.4",
"type-fest": "^5.7.0",
"uuid": "^14.0.0",
"ws": "^8.21.0"
},
"packageManager": "yarn@4.11.0"
}
4 changes: 2 additions & 2 deletions src/dev/services.mts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,7 @@ declare module "../user.mts" {
* > max: 6500
* > ```
*/
kelvin?: unknown;
color_temp_kelvin?: number;
/**
* ## Profile
*
Expand Down Expand Up @@ -1482,7 +1482,7 @@ declare module "../user.mts" {
* > max: 6500
* > ```
*/
kelvin?: unknown;
color_temp_kelvin?: number;
/**
* ## Profile
*
Expand Down
130 changes: 130 additions & 0 deletions src/mock_assistant/helpers/data-provider.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Dayjs } from "dayjs";

import type { ENTITY_PROP, ENTITY_STATE } from "../../helpers/utility.mts";
import type { ANY_ENTITY, PICK_ENTITY } from "../../user.mts";

/**
* The argument handed to a {@link MomentPredicate}.
*
* Pillar 4's solver calls predicates with a typed accessor over the world at a
* candidate instant, so a predicate reads like the natural-language scenario it
* encodes:
*
* ```ts
* provider.findMoment(
* ({ entity, time }) =>
* entity("binary_sensor.garage_door").is("open") && time.after("16:00"),
* );
* ```
*
* This type is intentionally pure (no runtime, no engine) — the simulation
* engine (Pillar 4) implements the accessors; the data layer only declares the
* contract they satisfy.
*/
export interface MomentContext {
/**
* Typed accessor for a single entity's reconstructed state at the candidate
* instant. The returned helper is narrowed to the picked entity id so
* `.is(...)` only accepts that entity's valid state literals.
*/
entity<ENTITY_ID extends ANY_ENTITY>(entity_id: ENTITY_ID): MomentEntity<ENTITY_ID>;
/** Accessor for the candidate instant itself, for time-based comparisons. */
time: MomentTime;
}

/**
* Typed view of one entity within a {@link MomentContext}, narrowed to the
* entity id that produced it.
*/
export interface MomentEntity<ENTITY_ID extends ANY_ENTITY> {
/** The reconstructed state for this entity at the candidate instant. */
readonly state: ENTITY_PROP<ENTITY_ID, "state">;
/** The reconstructed attributes for this entity at the candidate instant. */
readonly attributes: ENTITY_PROP<ENTITY_ID, "attributes">;
/** True when the entity's reconstructed state equals `expected`. */
is(expected: ENTITY_PROP<ENTITY_ID, "state">): boolean;
}

/**
* Typed view of the candidate instant within a {@link MomentContext}.
*
* Time literals (e.g. `"16:00"`) are interpreted against the fixed-TZ contract
* established by the time toolkit (Pillar 2); the engine — not this interface —
* owns that resolution.
*/
export interface MomentTime {
/** The candidate instant as an epoch-millisecond value. */
readonly at: number;
/** True when the candidate instant is at or after the given time. */
after(time: number | Dayjs | string): boolean;
/** True when the candidate instant is at or before the given time. */
before(time: number | Dayjs | string): boolean;
}

/**
* A typed predicate over a candidate moment, used by {@link DataProvider.findMoment}.
*
* Returns `true` for the moment the caller is searching for. v1 ships typed
* predicates only; a string DSL compiling to this same predicate shape is an
* explicit later phase (plan finding F12).
*/
export type MomentPredicate = (context: MomentContext) => boolean;

/**
* The result of a successful {@link DataProvider.findMoment} search.
*/
export interface FoundMoment {
/** The matched instant as an epoch-millisecond value. */
at: number;
/** The fully reconstructed world (entity_id → state) at the matched instant. */
world: Record<ANY_ENTITY, ENTITY_STATE<ANY_ENTITY>>;
}

/**
* Abstract source of historical / synthetic Home Assistant world data that the
* simulation engine (Pillar 4) reads through.
*
* This interface is pure and dependency-free: it carries NO runtime and NO
* native dependency (notably no `better-sqlite3`). Concrete feeders live in
* `home-automation` — a local read-only sqlite scraper (plan 3a) and a
* committed CI synthetic generator (plan 3b) — and the engine depends only on
* this contract, never on a feeder directly.
*/
export interface DataProvider {
/**
* Distinct observed states for an entity id across the provider's reference
* content, for matrix / combinatorial tests (plan finding F3).
*
* @param entity_id - the entity whose observed states to enumerate.
* @returns the de-duplicated set of states seen for that entity.
*/
observedValues<ENTITY_ID extends PICK_ENTITY>(
entity_id: ENTITY_ID,
): Array<ENTITY_PROP<ENTITY_ID, "state">>;

/**
* The fully reconstructed world (entity_id → {@link ENTITY_STATE}) at a given
* instant.
*
* Reconstruction is snapshot-plus-deltas (a full current-state snapshot with
* per-entity history deltas applied), which is why a single timestamp yields
* a complete world rather than a sparse window.
*
* @param ts - the instant to reconstruct, as epoch milliseconds or a `Dayjs`.
* @returns every known entity's state at that instant.
*/
worldAt(ts: number | Dayjs): Record<ANY_ENTITY, ENTITY_STATE<ANY_ENTITY>>;

/**
* Find the first / representative moment matching a typed predicate.
*
* Boots into a reconstructed real world for the matched instant. Throws when
* no recorded moment satisfies the predicate — a loud failure, never a
* fabricated world (plan Pillar 4 "Solver").
*
* @param predicate - typed predicate over the candidate moment.
* @returns the matched instant and its reconstructed world.
* @throws when no recorded moment satisfies `predicate`.
*/
findMoment(predicate: MomentPredicate): FoundMoment;
}
2 changes: 2 additions & 0 deletions src/mock_assistant/helpers/index.mts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./data-provider.mts";
export * from "./fixtures.mts";
export * from "./solar.mts";
Loading
Loading