| @@ -7,14 +7,16 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\API\V1; |
| 9 | 9 | |
| 10 | 10 | use WCPOS\WooCommercePOS\Logger; |
| 11 | -use WCPOS\WooCommercePOS\Interfaces\Push_Provider_Adapter_Interface; | |
| 12 | -use WCPOS\WooCommercePOS\Services\Print_Job_Lifecycle; | |
| 11 | +use WCPOS\WooCommercePOS\Services\Cloud_Print_Diagnostic; | |
| 12 | +use WCPOS\WooCommercePOS\Services\Cloud_Print_Media_Types; | |
| 13 | +use WCPOS\WooCommercePOS\Services\Cloud_Print_Poll_Request; | |
| 13 | 14 | use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service; |
| 14 | 15 | use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry; |
| 15 | 16 | use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service; |
| 16 | 17 | use WCPOS\WooCommercePOS\Services\PrintNode_Client; |
| 18 | +use WCPOS\WooCommercePOS\Services\Print_Format_Resolver; | |
| 17 | 19 | use WCPOS\WooCommercePOS\Services\Print_Job_Service; |
| 18 | 20 | use WCPOS\WooCommercePOS\Services\Provider; |
| 19 | 21 | use WCPOS\WooCommercePOS\Services\Star_Online_Client; |
| 20 | 22 | use WP_Error; |
| @@ -29,13 +31,19 @@ | ||
| 29 | 31 | * Print_Jobs_Controller class. |
| 30 | 32 | */ |
| 31 | 33 | class Print_Jobs_Controller extends WP_REST_Controller { |
| 32 | 34 | /** |
| 33 | - * Legacy Epson timeout. | |
| 35 | + * Milliseconds an Epson Server Direct Print printer waits for the local | |
| 36 | + * print device to become printable before it gives up and reports | |
| 37 | + * EX_TIMEOUT. | |
| 34 | 38 | * |
| 35 | - * @deprecated Use Epson_Sdp_Adapter::EPSON_SDP_PRINT_TIMEOUT_MS. | |
| 39 | + * Epson allows 5000–300000. The previous 10000 (near the floor) was | |
| 40 | + * unforgiving for printers that briefly go not-ready — a paper change, a | |
| 41 | + * sleep/wake, or a momentary network blip — declaring the job failed | |
| 42 | + * before the printer could recover. 60000 gives those transient states | |
| 43 | + * room to clear without letting a genuinely offline printer hang for long. | |
| 36 | 44 | */ |
| 37 | - const EPSON_SDP_PRINT_TIMEOUT_MS = \WCPOS\WooCommercePOS\Services\Providers\Epson_Sdp_Adapter::EPSON_SDP_PRINT_TIMEOUT_MS; | |
| 45 | + const EPSON_SDP_PRINT_TIMEOUT_MS = 60000; | |
| 38 | 46 | |
| 39 | 47 | /** |
| 40 | 48 | * Endpoint namespace. |
| 41 | 49 | * |
| @@ -764,15 +772,16 @@ | ||
| 764 | 772 | ); |
| 765 | 773 | } |
| 766 | 774 | $printer = $this->registry->get_printer( (string) $source['printer_id'] ); |
| 767 | 775 | if ( null !== $printer ) { |
| 768 | - // Provider::format() refreshes both halves together. A legacy job | |
| 769 | - // can carry a media type from before the provider declared its own, | |
| 776 | + // Refresh both halves of the pairing together. A legacy job can | |
| 777 | + // carry a media type from before the provider declared its own, | |
| 770 | 778 | // but content_type and pn_kind must keep agreeing: reprinting a |
| 771 | - // raw (escpos) PrintNode job through printer_content_type() | |
| 779 | + // raw (escpos) PrintNode job through the printer-only resolver | |
| 772 | 780 | // relabels it application/pdf in the queue view, even though |
| 773 | 781 | // submit still sends raw bytes off the stored pn_kind. |
| 774 | - $fmt = null === $template ? array( 'kind' => '' ) : Provider::format( $printer, $template ); | |
| 782 | + $resolver = new Print_Format_Resolver(); | |
| 783 | + $fmt = null === $template ? array( 'kind' => '' ) : $resolver->resolve( $printer, $template ); | |
| 775 | 784 | if ( '' === (string) $fmt['kind'] ) { |
| 776 | 785 | // No loadable template, or one this printer can no longer |
| 777 | 786 | // render. Refresh from the provider's declared type only |
| 778 | 787 | // when no stored kind can contradict it; otherwise the |
| @@ -777,9 +786,9 @@ | ||
| 777 | 786 | // render. Refresh from the provider's declared type only |
| 778 | 787 | // when no stored kind can contradict it; otherwise the |
| 779 | 788 | // source pairing is the best answer left. |
| 780 | 789 | if ( '' === $pn_kind ) { |
| 781 | - $content_type = Provider::printer_content_type( $printer ); | |
| 790 | + $content_type = $resolver->content_type_for_printer( $printer ); | |
| 782 | 791 | } |
| 783 | 792 | } else { |
| 784 | 793 | $content_type = $fmt['content_type']; |
| 785 | 794 | $pn_kind = Provider::stores_job_kind( Provider::normalize( (string) ( $printer['provider'] ?? '' ) ) ) |
| @@ -844,12 +853,206 @@ | ||
| 844 | 853 | * |
| 845 | 854 | * @return \WP_REST_Response|WP_Error |
| 846 | 855 | */ |
| 847 | 856 | public function cloudprnt( $request ) { |
| 848 | - return $this->handle_provider_poll( $request, 'star-cloudprnt' ); | |
| 857 | + $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) ); | |
| 858 | + $this->registry->record_seen( $printer_id ); | |
| 859 | + $this->jobs->release_stale_claims( $printer_id ); | |
| 860 | + | |
| 861 | + if ( 'POST' === $request->get_method() ) { | |
| 862 | + return $this->cloudprnt_poll( $request, $printer_id ); | |
| 863 | + } | |
| 864 | + | |
| 865 | + $job = $this->get_cloud_job_for_request( $request, $printer_id ); | |
| 866 | + if ( is_wp_error( $job ) ) { | |
| 867 | + return $job; | |
| 868 | + } | |
| 869 | + | |
| 870 | + if ( 'DELETE' === $request->get_method() ) { | |
| 871 | + $code = sanitize_text_field( (string) $request->get_param( 'code' ) ); | |
| 872 | + $status = '' === $code || '000' === $code || 1 === preg_match( '/^2\d{2,3}(?:\s|$)/', $code ) ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED; | |
| 873 | + $this->jobs->record_printer_result( (int) $job['id'], Print_Job_Service::STATUS_PRINTED === $status ); | |
| 874 | + | |
| 875 | + if ( Print_Job_Service::STATUS_FAILED === $status ) { | |
| 876 | + $this->log_printer_failure( $request, $printer_id, $code, (int) $job['id'] ); | |
| 877 | + } | |
| 878 | + | |
| 879 | + return rest_ensure_response( array( 'ok' => true ) ); | |
| 880 | + } | |
| 881 | + | |
| 882 | + return $this->cloudprnt_fetch( $request, $printer_id, $job ); | |
| 849 | 883 | } |
| 850 | 884 | |
| 851 | 885 | /** |
| 886 | + * Answer a CloudPRNT poll: offer a job, and ask what the printer can decode. | |
| 887 | + * | |
| 888 | + * The poll body is the printer's half of the conversation. `printingInProgress` | |
| 889 | + * says a job is still on the paper, and the spec is explicit that the server | |
| 890 | + * must not offer another one until it clears — doing so risks the printer | |
| 891 | + * dropping the second job. `clientAction` carries the printer's answers to | |
| 892 | + * questions asked in an earlier poll response, which is the only way the | |
| 893 | + * protocol exposes what formats the hardware can decode. | |
| 894 | + * | |
| 895 | + * @param WP_REST_Request $request Request. | |
| 896 | + * @param string $printer_id Printer ID. | |
| 897 | + * | |
| 898 | + * @return \WP_REST_Response | |
| 899 | + */ | |
| 900 | + private function cloudprnt_poll( WP_REST_Request $request, string $printer_id ) { | |
| 901 | + $poll = Cloud_Print_Poll_Request::from_body( (string) $request->get_body(), $request->get_json_params() ); | |
| 902 | + $this->registry->record_capabilities( $printer_id, $poll->answers(), $poll->status_code() ); | |
| 903 | + | |
| 904 | + $response = array( 'jobReady' => false ); | |
| 905 | + | |
| 906 | + if ( ! $poll->printing_in_progress() && ! $this->jobs->find_active_claim( $printer_id ) ) { | |
| 907 | + $job = $this->jobs->next_pending( $printer_id ); | |
| 908 | + if ( null !== $job ) { | |
| 909 | + // The offer is a list: the printer picks its preferred decodable | |
| 910 | + // entry and names it in the fetch's `?type`. Nothing decodable in | |
| 911 | + // the list means no GET at all, just a 510 confirmation — so the | |
| 912 | + // list is filtered by what this printer said it can decode. | |
| 913 | + // `mediaType` (singular) is kept for older firmware. | |
| 914 | + $media_types = $this->media_types_for_job( $job, $printer_id ); | |
| 915 | + $response = array( | |
| 916 | + 'jobReady' => true, | |
| 917 | + 'jobToken' => (string) $job['id'], | |
| 918 | + 'mediaType' => $media_types[0], | |
| 919 | + 'mediaTypes' => array_values( $media_types ), | |
| 920 | + ); | |
| 921 | + } | |
| 922 | + } | |
| 923 | + | |
| 924 | + if ( $this->registry->should_request_capabilities( $printer_id ) ) { | |
| 925 | + $response['clientAction'] = array( | |
| 926 | + array( 'request' => 'ClientType' ), | |
| 927 | + array( 'request' => 'Encodings' ), | |
| 928 | + ); | |
| 929 | + $this->registry->record_capability_request( $printer_id ); | |
| 930 | + } | |
| 931 | + | |
| 932 | + return rest_ensure_response( $response ); | |
| 933 | + } | |
| 934 | + | |
| 935 | + /** | |
| 936 | + * Serve a CloudPRNT job in the media type the printer asked for. | |
| 937 | + * | |
| 938 | + * @param WP_REST_Request $request Request. | |
| 939 | + * @param string $printer_id Printer ID. | |
| 940 | + * @param array $job Job array. | |
| 941 | + * | |
| 942 | + * @return \WP_REST_Response|WP_Error | |
| 943 | + */ | |
| 944 | + private function cloudprnt_fetch( WP_REST_Request $request, string $printer_id, array $job ) { | |
| 945 | + // The fetch GET names the printer's chosen media type. A type the server | |
| 946 | + // cannot produce is answered with 415 (per the CloudPRNT spec) and the job | |
| 947 | + // is left unclaimed. What is servable is deliberately wider than what the | |
| 948 | + // poll advertised: the printer naming a type is a stronger signal than our | |
| 949 | + // cached capability answer, so a capability update landing between the two | |
| 950 | + // requests must not reject a format we had just offered. Firmware that | |
| 951 | + // omits the parameter gets our best offer for this printer instead. The | |
| 952 | + // logged value is length-capped: printers poll every few seconds, so a | |
| 953 | + // wedged loop must not flood the log with unbounded input. | |
| 954 | + $servable = ( new Cloud_Print_Media_Types() )->servable_for_job( $job, $this->registry->get_printer( $printer_id ) ); | |
| 955 | + $requested = sanitize_text_field( (string) $request->get_param( 'type' ) ); | |
| 956 | + $chosen = '' === $requested | |
| 957 | + ? $this->media_types_for_job( $job, $printer_id )[0] | |
| 958 | + : Cloud_Print_Media_Types::match( $requested, $servable ); | |
| 959 | + | |
| 960 | + if ( '' === $chosen ) { | |
| 961 | + Logger::warning( | |
| 962 | + sprintf( | |
| 963 | + '%s: printer "%s" requested media type "%s" for print job %d, which the server can only serve as %s.', | |
| 964 | + $request->get_route(), | |
| 965 | + $printer_id, | |
| 966 | + substr( $requested, 0, 100 ), | |
| 967 | + (int) $job['id'], | |
| 968 | + implode( ', ', $servable ) | |
| 969 | + ) | |
| 970 | + ); | |
| 971 | + | |
| 972 | + return new WP_Error( | |
| 973 | + 'wcpos_print_job_incompatible_media_type', | |
| 974 | + __( 'The print job is not available in the requested media type.', 'woocommerce-pos' ), | |
| 975 | + array( 'status' => 415 ) | |
| 976 | + ); | |
| 977 | + } | |
| 978 | + | |
| 979 | + if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) { | |
| 980 | + return rest_ensure_response( array( 'jobReady' => false ) ); | |
| 981 | + } | |
| 982 | + | |
| 983 | + $render = $this->jobs->render_job( $job, $chosen ); | |
| 984 | + if ( '' === $render['body'] ) { | |
| 985 | + Logger::error( | |
| 986 | + sprintf( | |
| 987 | + '%s: print job %d rendered an empty payload for printer "%s".', | |
| 988 | + $request->get_route(), | |
| 989 | + (int) $job['id'], | |
| 990 | + $printer_id | |
| 991 | + ) | |
| 992 | + ); | |
| 993 | + } | |
| 994 | + | |
| 995 | + return $this->serve_raw( $render['body'], $chosen, self::control_headers( $chosen, $render ) ); | |
| 996 | + } | |
| 997 | + | |
| 998 | + /** | |
| 999 | + * The media types a CloudPRNT job can be served in, best first. | |
| 1000 | + * | |
| 1001 | + * @param array $job Job array. | |
| 1002 | + * @param string $printer_id Printer ID. | |
| 1003 | + * | |
| 1004 | + * @return array<int, string> | |
| 1005 | + */ | |
| 1006 | + private function media_types_for_job( array $job, string $printer_id ): array { | |
| 1007 | + $capabilities = $this->registry->get_capabilities( $printer_id ); | |
| 1008 | + | |
| 1009 | + return ( new Cloud_Print_Media_Types() )->for_job( | |
| 1010 | + $job, | |
| 1011 | + $this->registry->get_printer( $printer_id ), | |
| 1012 | + $capabilities['encodings'] | |
| 1013 | + ); | |
| 1014 | + } | |
| 1015 | + | |
| 1016 | + /** | |
| 1017 | + * Peripheral-control headers for a job served in a command-free format. | |
| 1018 | + * | |
| 1019 | + * `text/plain` and images carry no cut or drawer commands, so CloudPRNT reads | |
| 1020 | + * them off the fetch response instead. Command formats express both in-band | |
| 1021 | + * and must not also be told to cut, or the receipt cuts twice. | |
| 1022 | + * | |
| 1023 | + * Both headers are always sent, `none` included. Omitting them leaves the | |
| 1024 | + * decision to the printer's own defaults, which cut plain-text jobs — so a | |
| 1025 | + * template that deliberately does not cut would cut anyway, and would behave | |
| 1026 | + * differently in text than in StarPRNT. Saying `none` out loud keeps the two | |
| 1027 | + * formats rendering the same receipt. | |
| 1028 | + * | |
| 1029 | + * @param string $media_type The media type being served. | |
| 1030 | + * @param array $render Render result from Print_Job_Service::render_job(). | |
| 1031 | + * | |
| 1032 | + * @return array<string, string> | |
| 1033 | + */ | |
| 1034 | + private static function control_headers( string $media_type, array $render ): array { | |
| 1035 | + if ( ! Cloud_Print_Media_Types::is_header_controlled( $media_type ) ) { | |
| 1036 | + return array(); | |
| 1037 | + } | |
| 1038 | + | |
| 1039 | + $headers = array( | |
| 1040 | + 'X-Star-Cut' => null === $render['cut'] ? 'none' : (string) $render['cut'], | |
| 1041 | + 'X-Star-CashDrawer' => null === $render['drawer'] ? 'none' : (string) $render['drawer'], | |
| 1042 | + ); | |
| 1043 | + | |
| 1044 | + // The raster is already two-colour, so the printer's Floyd-Steinberg | |
| 1045 | + // default would dither an image that has nothing left to dither — | |
| 1046 | + // softening crisp black-on-white text into stipple. | |
| 1047 | + if ( Cloud_Print_Media_Types::PNG === Cloud_Print_Media_Types::normalize( $media_type ) ) { | |
| 1048 | + $headers['X-Star-ImageDitherPattern'] = 'none'; | |
| 1049 | + } | |
| 1050 | + | |
| 1051 | + return $headers; | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + /** | |
| 852 | 1055 | * Permission check for printer-token routes. |
| 853 | 1056 | * |
| 854 | 1057 | * @param WP_REST_Request $request Request. |
| 855 | 1058 | * |
| @@ -877,40 +1080,110 @@ | ||
| 877 | 1080 | |
| 878 | 1081 | return true; |
| 879 | 1082 | } |
| 880 | 1083 | |
| 1084 | + /** | |
| 1085 | + * Resolve and authorize a CloudPRNT job token. | |
| 1086 | + * | |
| 1087 | + * @param WP_REST_Request $request Request. | |
| 1088 | + * @param string $printer_id Printer ID. | |
| 1089 | + * | |
| 1090 | + * @return array|WP_Error | |
| 1091 | + */ | |
| 1092 | + private function get_cloud_job_for_request( WP_REST_Request $request, string $printer_id ) { | |
| 1093 | + $job_id = (int) $request->get_param( 'token' ); | |
| 1094 | + $job = $this->jobs->get( $job_id ); | |
| 1095 | + if ( null === $job || $printer_id !== $job['printer_id'] ) { | |
| 1096 | + Logger::warning( | |
| 1097 | + sprintf( | |
| 1098 | + '%s: print job "%d" was not found for printer "%s".', | |
| 1099 | + $request->get_route(), | |
| 1100 | + $job_id, | |
| 1101 | + $printer_id | |
| 1102 | + ) | |
| 1103 | + ); | |
| 881 | 1104 | |
| 1105 | + return new WP_Error( | |
| 1106 | + 'wcpos_print_job_not_found', | |
| 1107 | + __( 'Print job not found.', 'woocommerce-pos' ), | |
| 1108 | + array( 'status' => 404 ) | |
| 1109 | + ); | |
| 1110 | + } | |
| 882 | 1111 | |
| 1112 | + return $job; | |
| 1113 | + } | |
| 883 | 1114 | |
| 884 | 1115 | /** |
| 885 | - * Parse the vendor request and serve the lifecycle's plain-data response. | |
| 1116 | + * Log a printer-reported failure without request credentials or payloads. | |
| 886 | 1117 | * |
| 887 | 1118 | * @param WP_REST_Request $request Request. |
| 888 | - * @param string $provider_key Protocol selected by the route. | |
| 889 | - * @return WP_REST_Response|WP_Error | |
| 1119 | + * @param string $printer_id Printer ID. | |
| 1120 | + * @param string $code Failure code. | |
| 1121 | + * @param int $job_id Print job ID. | |
| 1122 | + * @param int|null $status Epson ePOS response status bitmask, when the result carried one. | |
| 890 | 1123 | */ |
| 891 | - private function handle_provider_poll( WP_REST_Request $request, string $provider_key ) { | |
| 892 | - $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) ); | |
| 893 | - $printer = $this->registry->get_printer( $printer_id ); | |
| 894 | - if ( null === $printer ) { | |
| 895 | - return new WP_Error( 'wcpos_print_job_invalid_token', __( 'Invalid printer token.', 'woocommerce-pos' ), array( 'status' => 401 ) ); | |
| 1124 | + private function log_printer_failure( WP_REST_Request $request, string $printer_id, string $code, int $job_id, ?int $status = null ): void { | |
| 1125 | + // One detail string serves both the stored reason and the log line, and it | |
| 1126 | + // keeps the raw hex even when no bit decodes, so an unmapped or | |
| 1127 | + // model-specific status can still be read back from a screenshot. | |
| 1128 | + $flags = null === $status ? '' : implode( ', ', self::describe_epson_status( $status ) ); | |
| 1129 | + $detail = null === $status ? '' : sprintf( ' (0x%08X%s)', $status, '' === $flags ? '' : ': ' . $flags ); | |
| 1130 | + $reason = $code . $detail; | |
| 1131 | + | |
| 1132 | + // Persist it too, not just log it. Push providers already record their | |
| 1133 | + // submission error against the job; polling printers report theirs here, | |
| 1134 | + // and without this the queue can only ever say "Failed" while the reason | |
| 1135 | + // (EX_TIMEOUT, a Star status code) is buried in the log. The decoded | |
| 1136 | + // Epson status bits are what turn "EX_TIMEOUT" into "cover open". | |
| 1137 | + if ( '' !== $reason ) { | |
| 1138 | + update_post_meta( $job_id, Print_Job_Service::META_ERROR, sanitize_text_field( $reason ) ); | |
| 896 | 1139 | } |
| 897 | - $poll = Provider::poll_adapter( $provider_key )->parse( | |
| 898 | - array( | |
| 899 | - 'params' => $request->get_params(), | |
| 900 | - 'body' => (string) $request->get_body(), | |
| 901 | - 'json' => 'POST' === $request->get_method() ? $request->get_json_params() : null, | |
| 902 | - 'method' => $request->get_method(), | |
| 903 | - 'route' => $request->get_route(), | |
| 1140 | + | |
| 1141 | + Logger::error( | |
| 1142 | + sprintf( | |
| 1143 | + '%s: printer "%s" reported failure code "%s"%s for print job %d.', | |
| 1144 | + $request->get_route(), | |
| 1145 | + $printer_id, | |
| 1146 | + $code, | |
| 1147 | + $detail, | |
| 1148 | + $job_id | |
| 904 | 1149 | ) |
| 905 | 1150 | ); |
| 906 | - $result = ( new Print_Job_Lifecycle( $this->jobs, $this->registry ) )->handle_poll( $provider_key, $printer, $poll ); | |
| 907 | - if ( \is_string( $result['body'] ) ) { | |
| 908 | - $response = $this->serve_raw( $result['body'], $result['headers']['Content-Type'], $result['headers'] ); | |
| 909 | - $response->set_status( $result['status'] ); | |
| 910 | - return $response; | |
| 1151 | + } | |
| 1152 | + | |
| 1153 | + /** | |
| 1154 | + * Decode an Epson ePOS-Print response status bitmask. | |
| 1155 | + * | |
| 1156 | + * @param int $status Decimal ASB status bitmask. | |
| 1157 | + * | |
| 1158 | + * @return array<int, string> | |
| 1159 | + */ | |
| 1160 | + private static function describe_epson_status( int $status ): array { | |
| 1161 | + // Epson ePOS-Print XML User's Manual, response `status` table (ASB bits). | |
| 1162 | + // Fault bits only: informational ones (print complete, drawer pin, feed | |
| 1163 | + // button, panel switch, buzzer) say nothing about why a print failed. | |
| 1164 | + $labels = array( | |
| 1165 | + 0x00000001 => __( 'no response from printer', 'woocommerce-pos' ), | |
| 1166 | + 0x00000008 => __( 'offline', 'woocommerce-pos' ), | |
| 1167 | + 0x00000020 => __( 'cover open', 'woocommerce-pos' ), | |
| 1168 | + 0x00000100 => __( 'waiting for online recovery', 'woocommerce-pos' ), | |
| 1169 | + 0x00000400 => __( 'mechanical error', 'woocommerce-pos' ), | |
| 1170 | + 0x00000800 => __( 'autocutter error', 'woocommerce-pos' ), | |
| 1171 | + 0x00002000 => __( 'unrecoverable error', 'woocommerce-pos' ), | |
| 1172 | + 0x00004000 => __( 'auto-recoverable error', 'woocommerce-pos' ), | |
| 1173 | + 0x00020000 => __( 'paper near end', 'woocommerce-pos' ), | |
| 1174 | + 0x00080000 => __( 'paper end', 'woocommerce-pos' ), | |
| 1175 | + 0x80000000 => __( 'spooler stopped', 'woocommerce-pos' ), | |
| 1176 | + ); | |
| 1177 | + | |
| 1178 | + $descriptions = array(); | |
| 1179 | + foreach ( $labels as $bit => $label ) { | |
| 1180 | + if ( 0 !== ( $status & $bit ) ) { | |
| 1181 | + $descriptions[] = $label; | |
| 1182 | + } | |
| 911 | 1183 | } |
| 912 | - return new WP_REST_Response( $result['body'], $result['status'], $result['headers'] ); | |
| 1184 | + | |
| 1185 | + return $descriptions; | |
| 913 | 1186 | } |
| 914 | 1187 | |
| 915 | 1188 | /** |
| 916 | 1189 | * Serve raw bytes from a REST callback. |
| @@ -933,9 +1206,155 @@ | ||
| 933 | 1206 | * |
| 934 | 1207 | * @return \WP_REST_Response |
| 935 | 1208 | */ |
| 936 | 1209 | public function epson_sdp( $request ) { |
| 937 | - return $this->handle_provider_poll( $request, 'epson-sdp' ); | |
| 1210 | + $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) ); | |
| 1211 | + $this->registry->record_seen( $printer_id ); | |
| 1212 | + $raw_body = (string) $request->get_body(); | |
| 1213 | + $soap = 'text/xml; charset=utf-8'; | |
| 1214 | + $ack = '<response success="true" code="" status=""/>'; | |
| 1215 | + | |
| 1216 | + $this->jobs->release_stale_claims( $printer_id ); | |
| 1217 | + | |
| 1218 | + // Server Direct Print multiplexes three different request types onto the | |
| 1219 | + // one configured URL, as URL-encoded form data distinguished by | |
| 1220 | + // ConnectionType (User's Manual Rev.K, ch.3 and the Test_print.php | |
| 1221 | + // reference implementation): | |
| 1222 | + // | |
| 1223 | + // GetRequest — poll for a job | |
| 1224 | + // SetResponse — printing result; the XML rides in the ResponseFile field | |
| 1225 | + // SetStatus — status notification; the XML rides in the Status field | |
| 1226 | + // | |
| 1227 | + // Answering a status notification with print data hands the job to a | |
| 1228 | + // request that discards it, so the printer never prints and the job stays | |
| 1229 | + // claimed. Dispatching on ConnectionType is what keeps the job on the | |
| 1230 | + // GetRequest that is actually asking for one. | |
| 1231 | + $connection_type = (string) $request->get_param( 'ConnectionType' ); | |
| 1232 | + | |
| 1233 | + // Result XML is a form field, so in the raw body it is percent-encoded | |
| 1234 | + // (`<response` arrives as `%3Cresponse`) and a raw-body substring test can | |
| 1235 | + // never match it. The raw-body branch is kept only for a caller that posts | |
| 1236 | + // the bare XML, which the printer never does. | |
| 1237 | + $result_xml = (string) $request->get_param( 'ResponseFile' ); | |
| 1238 | + if ( '' === $result_xml && false !== strpos( $raw_body, '<response' ) ) { | |
| 1239 | + $result_xml = $raw_body; | |
| 1240 | + } | |
| 1241 | + | |
| 1242 | + // A print result is recognised by its ePOS <response> element, whichever | |
| 1243 | + // way it arrived — the bare-XML caller sends no ConnectionType, so the | |
| 1244 | + // type is deliberately not consulted here. The printer also posts an | |
| 1245 | + // empty <PrintResponseInfo/> after every idle poll; reading that as a | |
| 1246 | + // result marked the in-flight job failed ("unknown"). It is acked below | |
| 1247 | + // and never dispatched on. | |
| 1248 | + if ( false !== strpos( $result_xml, '<response' ) ) { | |
| 1249 | + $claim = $this->jobs->find_active_claim( $printer_id ); | |
| 1250 | + if ( null === $claim ) { | |
| 1251 | + // A result that arrives after the claim timed out belongs to the job | |
| 1252 | + // that was failed as unconfirmed — record it there instead of | |
| 1253 | + // dropping it, so a printer that merely reported late shows the truth. | |
| 1254 | + $claim = $this->jobs->find_unconfirmed( $printer_id ); | |
| 1255 | + } else { | |
| 1256 | + // A result carries no job token — printjobid is SDP 2.00 only — so it | |
| 1257 | + // can only be attributed to the active claim. A printer holding an | |
| 1258 | + // unsent result retries that POST before polling for more work, so a | |
| 1259 | + // result should not arrive while an earlier job is still awaiting one. | |
| 1260 | + // Log it if it ever does: the attribution below would be the wrong job. | |
| 1261 | + $unconfirmed = $this->jobs->find_unconfirmed( $printer_id ); | |
| 1262 | + if ( null !== $unconfirmed ) { | |
| 1263 | + Logger::warning( | |
| 1264 | + sprintf( | |
| 1265 | + 'Printer "%s": result recorded against claimed job %d while unconfirmed job %d is still awaiting one.', | |
| 1266 | + $printer_id, | |
| 1267 | + (int) $claim['id'], | |
| 1268 | + (int) $unconfirmed['id'] | |
| 1269 | + ) | |
| 1270 | + ); | |
| 1271 | + } | |
| 1272 | + } | |
| 1273 | + if ( null !== $claim ) { | |
| 1274 | + $ok = false !== strpos( $result_xml, 'success="true"' ); | |
| 1275 | + $this->jobs->record_printer_result( (int) $claim['id'], $ok ); | |
| 1276 | + | |
| 1277 | + if ( ! $ok ) { | |
| 1278 | + $code = 'unknown'; | |
| 1279 | + if ( 1 === preg_match( '/\bcode="([^"]*)"/', $result_xml, $matches ) ) { | |
| 1280 | + $code = sanitize_text_field( $matches[1] ); | |
| 1281 | + } | |
| 1282 | + | |
| 1283 | + $status = null; | |
| 1284 | + if ( 1 === preg_match( '/\bstatus="(\d+)"/', $result_xml, $matches ) ) { | |
| 1285 | + // The ASB status is unsigned 32-bit; on a 32-bit PHP build a value | |
| 1286 | + // with bit 31 set does not fit and (int) would saturate to | |
| 1287 | + // 0x7FFFFFFF, decoding every label. Such a value is left undecoded | |
| 1288 | + // rather than misdecoded. | |
| 1289 | + $status = (float) $matches[1] <= PHP_INT_MAX ? (int) $matches[1] : null; | |
| 1290 | + } | |
| 1291 | + | |
| 1292 | + $this->log_printer_failure( $request, $printer_id, $code, (int) $claim['id'], $status ); | |
| 1293 | + } | |
| 1294 | + } | |
| 1295 | + | |
| 1296 | + return $this->serve_raw( $ack, $soap ); | |
| 1297 | + } | |
| 1298 | + | |
| 1299 | + // A status notification or a result post — typed, or recognisable only by | |
| 1300 | + // its ResponseFile — is not asking for work. Only the GetRequest lane below | |
| 1301 | + // (and a legacy untyped poll) may be handed a job. | |
| 1302 | + if ( 'SetStatus' === $connection_type || 'SetResponse' === $connection_type || '' !== $result_xml ) { | |
| 1303 | + return $this->serve_raw( $ack, $soap ); | |
| 1304 | + } | |
| 1305 | + | |
| 1306 | + if ( null !== $this->jobs->find_active_claim( $printer_id ) ) { | |
| 1307 | + return $this->serve_raw( $ack, $soap ); | |
| 1308 | + } | |
| 1309 | + | |
| 1310 | + $job = $this->jobs->next_pending( $printer_id ); | |
| 1311 | + if ( null === $job ) { | |
| 1312 | + return $this->serve_raw( $ack, $soap ); | |
| 1313 | + } | |
| 1314 | + | |
| 1315 | + if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) { | |
| 1316 | + return $this->serve_raw( $ack, $soap ); | |
| 1317 | + } | |
| 1318 | + $epos = $this->jobs->render_payload( $job ); | |
| 1319 | + | |
| 1320 | + // An empty render means the job produced nothing printable — most often | |
| 1321 | + // a template whose engine this provider cannot render (Server Direct | |
| 1322 | + // Print only speaks the thermal pipeline's ePOS-Print XML). Dispatching | |
| 1323 | + // it anyway sends <PrintData></PrintData>: the printer parses that | |
| 1324 | + // happily, prints nothing, and posts back success="true", so the job is | |
| 1325 | + // recorded as Printed. Fail the job here instead, so the queue shows the | |
| 1326 | + // truth and the log names the printer. | |
| 1327 | + if ( '' === $epos ) { | |
| 1328 | + Logger::error( | |
| 1329 | + sprintf( | |
| 1330 | + '%s: print job %d rendered an empty payload for printer "%s"; nothing was sent to the printer.', | |
| 1331 | + $request->get_route(), | |
| 1332 | + (int) $job['id'], | |
| 1333 | + $printer_id | |
| 1334 | + ) | |
| 1335 | + ); | |
| 1336 | + update_post_meta( (int) $job['id'], Print_Job_Service::META_ERROR, 'empty_rendered_payload' ); | |
| 1337 | + $this->jobs->set_status( (int) $job['id'], Print_Job_Service::STATUS_FAILED ); | |
| 1338 | + | |
| 1339 | + return $this->serve_raw( $ack, $soap ); | |
| 1340 | + } | |
| 1341 | + | |
| 1342 | + // Server Direct Print expects the print data wrapped in | |
| 1343 | + // PrintRequestInfo > ePOSPrint > PrintData — NOT the SOAP envelope used | |
| 1344 | + // by the direct ePOS-Print web service, which is a different protocol. | |
| 1345 | + // A printer that receives an unrecognised wrapper discards it silently: | |
| 1346 | + // it neither prints nor posts a result, so the job sits claimed forever. | |
| 1347 | + // Version 1.00 is the only version every SDP printer family supports | |
| 1348 | + // (Server Direct Print User's Manual Rev.K, "Response (Print request)"); | |
| 1349 | + // 2.00+ adds printjobid but is limited to TM-i/TM-DT/TM-T88VI. | |
| 1350 | + $envelope = '<?xml version="1.0" encoding="utf-8"?>'; | |
| 1351 | + $envelope .= '<PrintRequestInfo Version="1.00"><ePOSPrint>'; | |
| 1352 | + $envelope .= '<Parameter><devid>local_printer</devid><timeout>' . self::EPSON_SDP_PRINT_TIMEOUT_MS . '</timeout></Parameter>'; | |
| 1353 | + $envelope .= '<PrintData>' . $epos . '</PrintData>'; | |
| 1354 | + $envelope .= '</ePOSPrint></PrintRequestInfo>'; | |
| 1355 | + | |
| 1356 | + return $this->serve_raw( $envelope, $soap ); | |
| 938 | 1357 | } |
| 939 | 1358 | |
| 940 | 1359 | /** |
| 941 | 1360 | * Enqueue a print job (raw payload or order-based). |
| @@ -1096,14 +1515,26 @@ | ||
| 1096 | 1515 | // Legacy printer rows saved before the provider field existed must test |
| 1097 | 1516 | // as the default provider, not fall through to the no-diagnostic error. |
| 1098 | 1517 | $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null ); |
| 1099 | 1518 | |
| 1100 | - $adapter = Provider::adapter( $provider ); | |
| 1101 | - if ( $adapter instanceof Push_Provider_Adapter_Interface ) { | |
| 1102 | - return $adapter->test_print( $printer ); | |
| 1519 | + if ( 'printnode' === $provider ) { | |
| 1520 | + return $this->test_print_printnode( $printer ); | |
| 1103 | 1521 | } |
| 1104 | - $diag = $adapter->diagnostic( (string) $printer['name'] ); | |
| 1105 | 1522 | |
| 1523 | + if ( 'star-online' === $provider ) { | |
| 1524 | + return $this->test_print_star_online( $printer_id, $printer ); | |
| 1525 | + } | |
| 1526 | + | |
| 1527 | + try { | |
| 1528 | + $diag = ( new Cloud_Print_Diagnostic() )->build( $provider, (string) $printer['name'] ); | |
| 1529 | + } catch ( \RuntimeException $e ) { | |
| 1530 | + return new WP_Error( | |
| 1531 | + 'wcpos_print_job_no_diagnostic', | |
| 1532 | + __( 'Test print is not available for this printer yet.', 'woocommerce-pos' ), | |
| 1533 | + array( 'status' => 400 ) | |
| 1534 | + ); | |
| 1535 | + } | |
| 1536 | + | |
| 1106 | 1537 | $id = $this->jobs->create( |
| 1107 | 1538 | array( |
| 1108 | 1539 | 'printer_id' => $printer_id, |
| 1109 | 1540 | 'content_type' => $diag['content_type'], |
| @@ -1123,9 +1554,100 @@ | ||
| 1123 | 1554 | |
| 1124 | 1555 | return $response; |
| 1125 | 1556 | } |
| 1126 | 1557 | |
| 1558 | + /** | |
| 1559 | + * Queue a Star Markup test receipt and submit it through the push pipeline. | |
| 1560 | + * | |
| 1561 | + * @param string $printer_id Registered printer id. | |
| 1562 | + * @param array $printer Registered star-online printer. | |
| 1563 | + * | |
| 1564 | + * @return \WP_REST_Response|WP_Error | |
| 1565 | + */ | |
| 1566 | + private function test_print_star_online( string $printer_id, array $printer ) { | |
| 1567 | + $markup = ( new Cloud_Print_Diagnostic() )->star_markup( (string) $printer['name'] ); | |
| 1127 | 1568 | |
| 1569 | + $id = $this->jobs->create( | |
| 1570 | + array( | |
| 1571 | + 'printer_id' => $printer_id, | |
| 1572 | + 'content_type' => 'text/vnd.star.markup', | |
| 1573 | + 'payload' => base64_encode( $markup ), | |
| 1574 | + ) | |
| 1575 | + ); | |
| 1576 | + if ( $id <= 0 ) { | |
| 1577 | + return new WP_Error( | |
| 1578 | + 'wcpos_print_job_create_failed', | |
| 1579 | + __( 'Print job could not be created.', 'woocommerce-pos' ), | |
| 1580 | + array( 'status' => 500 ) | |
| 1581 | + ); | |
| 1582 | + } | |
| 1583 | + | |
| 1584 | + wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $id ) ); | |
| 1585 | + ( new \WCPOS\WooCommercePOS\Services\Cloud_Print_Submit_Service() )->submit( $id ); | |
| 1586 | + | |
| 1587 | + $response = rest_ensure_response( $this->jobs->get( $id ) ); | |
| 1588 | + $response->set_status( 201 ); | |
| 1589 | + | |
| 1590 | + return $response; | |
| 1591 | + } | |
| 1592 | + | |
| 1593 | + /** | |
| 1594 | + * Submit a diagnostic PDF to a PrintNode printer. | |
| 1595 | + * | |
| 1596 | + * @param array $printer Registered PrintNode printer. | |
| 1597 | + * | |
| 1598 | + * @return \WP_REST_Response|WP_Error | |
| 1599 | + */ | |
| 1600 | + private function test_print_printnode( array $printer ) { | |
| 1601 | + $api_key = (string) ( $printer['printnode_api_key'] ?? '' ); | |
| 1602 | + $pn_printer_id = (int) ( $printer['printnode_printer_id'] ?? 0 ); | |
| 1603 | + if ( '' === $api_key || 0 === $pn_printer_id ) { | |
| 1604 | + return new WP_Error( | |
| 1605 | + 'wcpos_print_job_printnode_unconfigured', | |
| 1606 | + __( 'This PrintNode printer is missing its API key or printer id.', 'woocommerce-pos' ), | |
| 1607 | + array( 'status' => 400 ) | |
| 1608 | + ); | |
| 1609 | + } | |
| 1610 | + | |
| 1611 | + try { | |
| 1612 | + $pdf = ( new Cloud_Print_Diagnostic() )->build_pdf( (string) $printer['name'] ); | |
| 1613 | + } catch ( \Throwable $e ) { | |
| 1614 | + // Defense in depth: a Dompdf/font-cache/temp-dir failure must not | |
| 1615 | + // surface as an uncaught 500. Mirror the render_payload() guard. | |
| 1616 | + Logger::log( 'Cloud print: PrintNode diagnostic PDF render failed: ' . $e->getMessage() ); | |
| 1617 | + | |
| 1618 | + return new WP_Error( | |
| 1619 | + 'wcpos_print_job_diagnostic_failed', | |
| 1620 | + __( 'Could not generate the test print.', 'woocommerce-pos' ), | |
| 1621 | + array( 'status' => 500 ) | |
| 1622 | + ); | |
| 1623 | + } | |
| 1624 | + | |
| 1625 | + $result = ( new PrintNode_Client( $api_key ) )->submit_job( | |
| 1626 | + $pn_printer_id, | |
| 1627 | + 'WCPOS Test Print', | |
| 1628 | + 'pdf_base64', | |
| 1629 | + base64_encode( $pdf ) | |
| 1630 | + ); | |
| 1631 | + | |
| 1632 | + if ( is_wp_error( $result ) ) { | |
| 1633 | + return new WP_Error( | |
| 1634 | + 'wcpos_print_job_printnode_failed', | |
| 1635 | + $result->get_error_message(), | |
| 1636 | + array( 'status' => 502 ) | |
| 1637 | + ); | |
| 1638 | + } | |
| 1639 | + | |
| 1640 | + return new WP_REST_Response( | |
| 1641 | + array( | |
| 1642 | + 'submitted' => true, | |
| 1643 | + 'external_provider' => 'printnode', | |
| 1644 | + 'external_job_id' => (string) $result['id'], | |
| 1645 | + 'external_state' => 'submitted', | |
| 1646 | + ), | |
| 1647 | + 201 | |
| 1648 | + ); | |
| 1649 | + } | |
| 1128 | 1650 | |
| 1129 | 1651 | /** |
| 1130 | 1652 | * Extract sanitized cash-drawer options from a REST request. |
| 1131 | 1653 | * |